Tutorial Laravel 12, Filament, Spatie, Midtrans - Website Booking Ticket Event

Halo teman-teman! Kali ini kita akan masuk ke bagian yang sangat seru dan fundamental dalam pengembangan website booking event ticket, yaitu membangun halaman admin yang lengkap dengan Content Management System (CMS) menggunakan Laravel dan Filament. Setelah sebelumnya kita sudah membahas berbagai aspek dari website booking, sekarang saatnya kita fokus pada "dapur" dari aplikasi kita.

Mengapa Halaman Admin Itu Sangat Penting?

Sebelum kita mulai coding, ada baiknya kita pahami dulu kenapa halaman admin ini jadi komponen yang nggak bisa diabaikan dalam website booking event ticket. Bayangkan kalau kamu punya bisnis event organtizer yang berkembang pesat, tapi masih harus mengelola semua data secara manual atau bahkan langsung dari database. Ribet banget kan?

Halaman admin yang terstruktur dengan baik itu seperti command center untuk seluruh operasional bisnis kamu. Di sinilah semua magic terjadi - mulai dari mengelola event-event yang akan diselenggarakan, mengatur kategori tiket dengan harga yang berbeda-beda, memantau siapa saja yang sudah membeli tiket, sampai melihat laporan penjualan yang real-time.

Pentingnya Struktur yang Rapi dalam Pengelolaan Data

Ketika kita bicara tentang website booking event ticket, kompleksitasnya nggak main-main lho. Kita nggak cuma deal dengan data event aja, tapi juga harus mengelola berbagai jenis informasi yang saling terkait. Ada data event dengan detail lengkapnya seperti nama, tanggal, lokasi, dan deskripsi. Ada juga data kategori tiket yang mungkin punya variasi harga seperti early bird, regular, atau VIP.

Belum lagi data customer yang beli tiket, riwayat transaksi mereka, status pembayaran, dan masih banyak lagi. Kalau semua ini nggak dikelola dengan sistem yang terstruktur, dijamin bakal jadi nightmare buat admin yang harus handle semuanya.

Filament: Solusi Elegant untuk Admin Panel

Nah, di sinilah Filament berperan sebagai game changer. Filament ini adalah admin panel yang dibangun khusus untuk Laravel, dan trust me, ini adalah salah satu tool terbaik yang pernah ada untuk membuat interface admin yang powerful tapi tetap user-friendly.

Yang bikin Filament istimewa adalah pendekatan component-based nya. Jadi instead of kita harus coding semua interface dari scratch, Filament udah provide berbagai komponen siap pakai yang bisa kita customize sesuai kebutuhan. Mulai dari form yang interaktif, table dengan fitur sorting dan filtering yang advanced, sampai dashboard dengan chart dan statistik yang eye-catching.

Manajemen Produk Event yang Efisien

Dalam konteks website booking event ticket, pengelolaan produk event jadi salah satu aspek yang paling krusial. Bayangin aja, event organizer biasanya punya banyak event yang berjalan secara bersamaan atau berurutan. Ada yang masih dalam tahap planning, ada yang sedang dalam periode penjualan tiket, ada juga yang udah selesai tapi masih perlu ditrack untuk keperluan evaluasi.

Dengan halaman admein yang proper, kita bisa dengan mudah mengategorikan event berdasarkan statusnya, mengatur visibility event (mana yang udah bisa dipublikasi, mana yang masih draft), dan bahkan mengatur schedule untuk automatic publishing. Ini semua bakal sangat membantu workflow tim marketing dan operations.

Sistem Pengelolaan Pesanan yang Comprehensive

Aspek lain yang nggak kalah penting adalah pengelolaan pesanan atau booking. Di sinilah kompleksitas sebenarnya muncul, karena kita harus handle berbagai skenario yang mungkin terjadi. Ada customer yang booking tapi belum bayar, ada yang sudah bayar tapi mungkin minta refund, ada juga yang booking dalam jumlah banyak untuk grup.

Halaman admin harus bisa provide visibility yang jelas untuk semua skenario ini. Admin harus bisa dengan cepat melihat status setiap pesanan, melakukan tindakan yang diperlukan seperti konfirmasi pembayaran manual, processing refund, atau bahkan komunikasi langsung dengan customer melalui sistem yang terintegrasi.

Database Customer yang Valuable

Customer data adalah aset yang sangat berharga untuk bisnis event. Nggak cuma sekedar nama dan email, tapi juga behavioral data seperti jenis event apa yang sering mereka attend, berapa rata-rata spending mereka, apakah mereka repeat customer atau one-time buyer.

Halaman admin yang baik harus bisa provide insight tentang customer base ini. Dengan data yang terorganisir dengan baik, kita bisa melakukan segmentasi customer untuk campaign marketing yang lebih targeted, atau bahkan memberikan personalized recommendation untuk event-event yang upcoming.

Laporan dan Analytics yang Actionable

Last but not least, laporan penjualan dan analytics. Ini adalah bagian yang seringkali underestimated, padahal justru dari sini kita bisa mendapatkan insight yang paling valuable untuk business decision making.

Laporan yang comprehensive bukan cuma tentang berapa total penjualan dalam periode tertentu, tapi juga breakdown per event, per kategori tiket, trend penjualan dari waktu ke waktu, dan bahkan predictive analytics untuk event-event yang akan datang. Dengan data yang akurat dan presentation yang clear, stakeholder bisa dengan mudah memahami performance bisnis dan membuat strategic planning yang lebih informed.

Persiapan untuk Journey Selanjutnya

Nah, dengan pemahaman yang solid tentang pentingnya halaman admin ini, kita udah ready untuk mulai hands-on dengan Laraveel dan Filament. Di artikel-artikel selanjutnya, kita akan step by step membangun setiap komponen yang udah kita bahas tadi.

Kita akan mulai dari setup project Laravel yang fresh, instalasi dan konfigurasi Filament, setup database schema yang optimal, sampai implementasi setiap feature yang dibutuhkan. Semuanya akan kita lakukan dengan best practices yang udah proven di production environment.

So, siapkan kopi dan mood coding yang semangat, karena journey kita dalam membangun website booking event ticket yang professional baru saja dimulai!

Memulai Project Laravel Baru dengan Composer

Oke teman-teman, sekarang saatnya kita mulai hands-on! Langkah pertama yang harus kita lakukan adalah membuat project Laravel yang fresh. Di tutorial ini kita akan menggunakan Laravel versi terbaru, dan cara paling reliable untuk install Laravel adalah menggunakan Composer.

Pastikan dulu kamu sudah punya Composer terinstall di sistem kamu. Kalau belum, bisa download dari getcomposer.org. Setelah itu, buka terminal atau command prompt, navigasi ke folder tempat kamu mau simpan project, dan jalankan command berikut:

composer create-project laravel/laravel booking-event-ticket

Command ini akan membuat folder baru bernama booking-event-ticket dan menginstall Laravel beserta semua dependencies yang dibutuhkan. Proses ini biasanya butuh beberapa menit tergantung kecepatan internet kamu, jadi santai aja sambil minum kopi.

Setelah proses instalasi selesai, masuk ke directory project yang baru dibuat:

cd booking-event-ticket

Nah, sekarang kamu udah punya project Laravel yang fresh dan siap untuk dikembangkan!

Memahami Struktur Project Laravel

Sebelum lanjut ke konfigurasi database, ada baiknya kita familiar dulu dengan struktur folder yang baru aja dibuat. Kalau kamu buka project di file explorer atau code editor, kamu akan lihat struktur seperti ini:

booking-event-ticket/
├── app/
├── bootstrap/
├── config/
├── database/
├── public/
├── resources/
├── routes/
├── storage/
├── tests/
├── vendor/
├── .env
├── artisan
├── composer.json
└── ...

Yang paling penting untuk diperhatikan saat ini adalah file .env di root directory. File inilah yang akan kita edit untuk konfigurasi database.

Mengatur Database Connection di File .env

Salah satu hal pertama yang haruas kita setup setelah install Laravel adalah konfigurasi database. Laravel menggunakan file .env untuk menyimpan environment variables, termasuk konfigurasi database. File ini berada di root directory project kamu.

Buka file .env menggunakan text editor favorit kamu. Di sana kamu akan menemukan beberapa baris yang berkaitan dengan database configuration:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=

Sekarang kita perlu adjust konfigurasi ini sesuai dengan setup database kamu. Mari kita bahas satu-satu parameter yang ada:

Mengatur DB_CONNECTION

Parameter DB_CONNECTION menentukan jenis database yang akan kita gunakan. Dalam tutorial ini kita akan pakai MySQL, jadi pastikan nilainya adalah:

DB_CONNECTION=mysql

Laravel support berbagai database seperti MySQL, PostgreSQL, SQLite, dan SQL Server. Tapi untuk project booking event ticket ini, MySQL adalah pilihan yang paling common dan reliable.

Konfigurasi DB_HOST

DB_HOST adalah alamat server database kamu. Kalau kamu running MySQL di komputer lokal (localhost), maka nilainya tetap:

DB_HOST=127.0.0.1

Tapi kalau kamu pakai service cloud seperti AWS RDS, Google Cloud SQL, atau hosting provider lain, kamu perlu ganti dengan hostname yang mereka provide. Contohnya:

DB_HOST=mysql-instance.abc123.us-east-1.rds.amazonaws.com

Setting DB_PORT

Port default untuk MySQL adalah 3306, jadi biasanya kamu nggak perlu ubah ini:

DB_PORT=3306

Kecuali kalau setup MySQL kamu menggunakan custom port, baru deh kamu adjust sesuai kebutuhan.

Menentukan DB_DATABASE

DB_DATABASE adalah nama database yang akan digunakan untuk project ini. Ganti nilai default laravel dengan nama yang lebih descriptive:

DB_DATABASE=booking_event_ticket

Pastikan database dengan nama ini sudah dibuat di MySQL server kamu. Kalau belum, kamu bisa buat manual pakai phpMyAdmin, MySQL Workbench, atau command line:

CREATE DATABASE booking_event_ticket CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

Mengatur DB_USERNAME dan DB_PASSWORD

Untuk username dan password, sesuaeikan dengan kredensial MySQL kamu:

DB_USERNAME=root
DB_PASSWORD=your_password_here

Kalau kamu pakai XAMPP atau MAMP di development environment, biasanya username default adalah root dengan password kosong. Tapi kalau di production atau menggunakan service cloud, pastikan kamu pakai credentials yang benar dan secure.

Contoh Konfigurasi .env Lengkap

Berikut adalah contoh lengkap bagian database configuration di file .env:

# Database Configuration
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=booking_event_ticket
DB_USERNAME=root
DB_PASSWORD=secretpassword

Verifikasi Koneksi Database

Setelah konfigurasi .env sudah diatur, kita perlu verify apakah Laravel bisa connect ke database. Cara termudah adalah dengan menjalankan command artisan untuk check koneksi:

php artisan migrate:status

Kalau konfigurasi benar, kamu akan lihat outaput yang menunjukkan status migration table (meskipun masih kosong). Kalau ada error, biasanya masalahnya di credentials atau database yang belum dibuat.

Tips Keamanan untuk File .env

Satu hal yang super penting: jangan pernah commit file .env ke version control seperti Git! File ini contains sensitive information seperti database password dan API keys. Laravel udah secara default memasukkan .env ke dalam .gitignore, tapi double check untuk memastikan.

Untuk production environment, kamu harus buat file .env terpisah dengan credentials yang berbeda dan lebih secure. Jangan pakai credentials development di production!

Troubleshooting Common Issues

Kalau kamu mengalami error "Access denied" atau "Connection refused", coba check beberapa hal ini:

Pastikan MySQL service sudah running di komputer kamu. Di XAMPP bisa check melalui control panel, di MAMP juga ada interface untuk start/stop services.

Verify username dan password dengan login manual ke MySQL:

mysql -u root -p

Kalau bisa login manual tapi Laravel masih error, kemungkinan ada typo di file .env.

Check juga apakah database yang kamu specify di DB_DATABASE sudah benar-benar exist di MySQL server.

Next Steps

Setelah database connection berhasil disetup, kita udah ready untuk langkah selanjutnya yaitu install dan konfigurasi Filament. Tapi sebelum itu, ada baiknya kita test dulu apakah Laravel project kita berjalan dengan baik:

php artisan serve

Command ini akan start development server di http://localhost:8000. Buka URL tersebut di browser, dan kamu harus lihat welcome page Laravel yang menandakan everything is working properly!

Memahami Database Schema untuk Booking Event Ticket

Sebelum kita mulai membuat migration dan model, ada baiknya kita pahami dulu struktur database yang akan kita bangun. Untuk websiite booking event ticket, kita butuh minimal 4 tabel utama: categories, products, customers, dan orders. Setiap tabel punya peran dan relationship yang spesifik dalam ecosystem aplikasi kita.

Tabel categories akan menyimpan kategori event seperti "Music Concert", "Workshop", "Conference", dll. Tabel products adalah tabel utama yang menyimpan data event/tiket. Tabel customers untuk data pembeli, dan tabel orders untuk menyimpan transaksi pembelian. Relationship antar tabel ini akan membentuk struktur data yang solid dan scalable.

Membuat Migration untuk Tabel Categories

Mari kita mulai dengan tabel yang paling sederhana dulu, yaitu categories. Buka terminal di root directory project Laravel kamu dan jalankan artisan command berikut:

php artisan make:migration create_categories_table

Command ini akan generate file migration baru di folder database/migrations/. File akan punya nama seperti 2024_01_01_000000_create_categories_table.php dengan timestamp otomatis.

Buka file migration yang baru dibuat dan edit method up() 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('categories', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description')->nullable();
            $table->string('icon')->nullable();
            $table->boolean('is_active')->default(true);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('categories');
    }
};

Migration untuk Tabel Products (Events)

Selanjutnya kita buat migration untuk tabel products yang akan menyimpan data event/tiket:

php artisan make:migration create_products_table

Edit file migration yang dibuat:

<?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('products', function (Blueprint $table) {
            $table->id();
            $table->foreignId('category_id')->constrained()->onDelete('cascade');
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description');
            $table->text('short_description')->nullable();
            $table->string('featured_image')->nullable();
            $table->json('gallery')->nullable();
            $table->decimal('price', 12, 2);
            $table->decimal('discount_price', 12, 2)->nullable();
            $table->integer('stock')->default(0);
            $table->datetime('event_date');
            $table->string('event_location');
            $table->time('event_time');
            $table->json('event_details')->nullable();
            $table->enum('status', ['draft', 'published', 'sold_out', 'cancelled'])->default('draft');
            $table->boolean('is_featured')->default(false);
            $table->integer('views')->default(0);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('products');
    }
};

Migration untuk Tabel Customers

Sekarang kita buat tabel untuk menyimpan data customer:

php artisan make:migration create_customers_table

Isi migration file-nya:

<?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('customers', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('phone')->nullable();
            $table->date('birth_date')->nullable();
            $table->enum('gender', ['male', 'female', 'other'])->nullable();
            $table->text('address')->nullable();
            $table->string('city')->nullable();
            $table->string('province')->nullable();
            $table->string('postal_code')->nullable();
            $table->string('password');
            $table->boolean('is_active')->default(true);
            $table->timestamp('last_login_at')->nullable();
            $table->rememberToken();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('customers');
    }
};

Migration untuk Tabel Orders

Last but not least, kita buat tabel orders untuk menyimpan data transaksi:

php artisan make:migration create_orders_table

Edit migration file:

<?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('orders', function (Blueprint $table) {
            $table->id();
            $table->string('order_number')->unique();
            $table->foreignId('customer_id')->constrained()->onDelete('cascade');
            $table->foreignId('product_id')->constrained()->onDelete('cascade');
            $table->integer('quantity')->default(1);
            $table->decimal('unit_price', 12, 2);
            $table->decimal('total_price', 12, 2);
            $table->decimal('discount_amount', 12, 2)->default(0);
            $table->decimal('final_amount', 12, 2);
            $table->enum('status', ['pending', 'paid', 'cancelled', 'refunded'])->default('pending');
            $table->enum('payment_status', ['unpaid', 'paid', 'partial', 'refunded'])->default('unpaid');
            $table->string('payment_method')->nullable();
            $table->string('payment_reference')->nullable();
            $table->timestamp('payment_date')->nullable();
            $table->json('customer_details');
            $table->json('payment_details')->nullable();
            $table->text('notes')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('orders');
    }
};

Menjalankan Migration

Setelah semua migration file sudah dibbuat, kita perlu menjalankan migration untuk create tabel-tabel tersebut di database:

php artisan migrate

Command ini akan execute semua migration yang belum dijalankan. Kalau ada error, periksa kembali syntax di migration file atau pastikan database connection sudah benar.

Membuat Model Category

Sekarang kita lanjut ke pembuatan model. Maari mulai dengan model Category:

php artisan make:model Category

Buka file app/Models/Category.php dan edit seperti ini:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;

class Category extends Model
{
    use HasFactory;

    protected $fillable = [
        'name',
        'slug',
        'description',
        'icon',
        'is_active',
    ];

    protected $casts = [
        'is_active' => 'boolean',
    ];

    public function products(): HasMany
    {
        return $this->hasMany(Product::class);
    }

    public function activeProducts(): HasMany
    {
        return $this->hasMany(Product::class)->where('status', 'published');
    }
}

Model Product dengan Relationship

Selanjutnya buat model Producttt:

php artisan make:model Product

Edit file app/Models/Product.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;

class Product extends Model
{
    use HasFactory;

    protected $fillable = [
        'category_id',
        'name',
        'slug',
        'description',
        'short_description',
        'featured_image',
        'gallery',
        'price',
        'discount_price',
        'stock',
        'event_date',
        'event_location',
        'event_time',
        'event_details',
        'status',
        'is_featured',
        'views',
    ];

    protected $casts = [
        'gallery' => 'array',
        'event_details' => 'array',
        'event_date' => 'datetime',
        'event_time' => 'datetime',
        'price' => 'decimal:2',
        'discount_price' => 'decimal:2',
        'is_featured' => 'boolean',
        'views' => 'integer',
        'stock' => 'integer',
    ];

    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

    public function orders(): HasMany
    {
        return $this->hasMany(Order::class);
    }

    public function customers()
    {
        return $this->belongsToMany(Customer::class, 'orders')
                    ->withPivot(['quantity', 'unit_price', 'total_price', 'status'])
                    ->withTimestamps();
    }

    // Accessor untuk mendapatkan harga yang sedang aktif
    public function getCurrentPriceAttribute()
    {
        return $this->discount_price ?? $this->price;
    }

    // Scope untuk produk yang masih available
    public function scopeAvailable($query)
    {
        return $query->where('status', 'published')
                    ->where('stock', '>', 0)
                    ->where('event_date', '>', now());
    }
}

Model Customer

Buat model Customer:

php artisan make:model Customer

Edit app/Models/Customer.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Notifications\\Notifiable;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;

class Customer extends Authenticatable
{
    use HasFactory, Notifiable;

    protected $fillable = [
        'name',
        'email',
        'phone',
        'birth_date',
        'gender',
        'address',
        'city',
        'province',
        'postal_code',
        'password',
        'is_active',
        'last_login_at',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'birth_date' => 'date',
        'is_active' => 'boolean',
        'last_login_at' => 'datetime',
        'password' => 'hashed',
    ];

    public function orders(): HasMany
    {
        return $this->hasMany(Order::class);
    }

    public function products()
    {
        return $this->belongsToMany(Product::class, 'orders')
                    ->withPivot(['quantity', 'unit_price', 'total_price', 'status'])
                    ->withTimestamps();
    }

    // Accessor untuk mendapatkan nama lengkap
    public function getFullNameAttribute()
    {
        return $this->name;
    }

    // Scope untuk customer aktif
    public function scopeActive($query)
    {
        return $query->where('is_active', true);
    }
}

Model Order dengan Complex Relationship

Terakhir, buat model Order:

php artisan make:model Order

Edit app/Models/Order.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;

class Order extends Model
{
    use HasFactory;

    protected $fillable = [
        'order_number',
        'customer_id',
        'product_id',
        'quantity',
        'unit_price',
        'total_price',
        'discount_amount',
        'final_amount',
        'status',
        'payment_status',
        'payment_method',
        'payment_reference',
        'payment_date',
        'customer_details',
        'payment_details',
        'notes',
    ];

    protected $casts = [
        'unit_price' => 'decimal:2',
        'total_price' => 'decimal:2',
        'discount_amount' => 'decimal:2',
        'final_amount' => 'decimal:2',
        'payment_date' => 'datetime',
        'customer_details' => 'array',
        'payment_details' => 'array',
        'quantity' => 'integer',
    ];

    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }

    // Boot method untuk generate order number otomatis
    protected static function boot()
    {
        parent::boot();

        static::creating(function ($order) {
            if (empty($order->order_number)) {
                $order->order_number = 'ORD-' . date('Ymd') . '-' . str_pad(mt_rand(1, 9999), 4, '0', STR_PAD_LEFT);
            }
        });
    }

    // Scope untuk orders yang sudah dibayar
    public function scopePaid($query)
    {
        return $query->where('payment_status', 'paid');
    }

    // Scope untuk orders pending
    public function scopePending($query)
    {
        return $query->where('status', 'pending');
    }
}

Memahami Fillable Property

Property $fillable dalam setiap model berfungsi sebagai whitelist untuk mass assignment. Artinya, hanya field yang ada dalam array $fillable yang bisa diisi secara bulk menggunakan method seperti create() atau update(). Ini adalah security feature untuk mencegah mass assignment vulnerability.

Misalnya, ketika kita melakukan:

Product::create([
    'name' => 'Laravel BWA 2024',
    'price' => 150000,
    'malicious_field' => 'hacker_value'
]);

Field malicious_field akan diabaikan karena tidak ada dalam $fillable, jadi cuma name dan price yang benar-benar disimpan ke database.

Understanding Eloquent Relationships

Relationship yang kita setup di model-model tadi mengikuti pola yang umum digunakan dalam aplikasi e-commerce:

hasMany digunakan ketika satu record bisa punya banyak record terkait. Misalnya, satu Category bisa punya banyak Product.

belongsTo adalah kebalikan dari hasMany. Satu Product belongs to satu Category.

belongsToMany digunakan untuk many-to-many relationship melalui pivot table. Dalam kasus kita, Customer dan Product punya many-to-many relationship melalui Orders table.

Testing Relationship

Setelah semua model dan migration sudah dibuat, kamu bisa test relationship dengan menjalankan beberapa command di Tinker:

php artisan tinker

Di dalam Tinker, coba buat beberapa dummy data:

$category = Category::create([
    'name' => 'Music Concert',
    'slug' => 'music-concert',
    'description' => 'Live music performances'
]);

$product = Product::create([
    'category_id' => $category->id,
    'name' => 'Rock Festival 2024',
    'slug' => 'rock-festival-2024',
    'description' => 'The biggest rock festival',
    'price' => 250000,
    'stock' => 1000,
    'event_date' => '2024-12-01',
    'event_location' => 'Jakarta Convention Center',
    'event_time' => '19:00:00',
    'status' => 'published'
]);

// Test relationship
$category->products; // Akan return collection of products
$product->category; // Akan return category object

Dengan setup model dan migration yang solid seperti ini, kita udah punya foundation yang kuat untuk membangun fitur-fitur advance di tahap selanjutnya!

Mengenal Filament Admin Panel

Sebelum kita mulai install Filament, ada baiknya kita pahami dulu apa itu Filament dan kenapa ini jadi pilihan yang excellent untuk admin panel Laravel. Filament adalah admin panel yang dibangun khusus untuk Laravel dengan pendekatan modern dan component-based. Yang bikin Filament istimewa adalah kemudahan penggunaannya tanpa mengorbankan flexibility dan power.

Dibanding dengan admin panel lain seperti Laravel Nova atau ActiveAdmin, Filament provide balance yang perfect antara ease of use dan customization capability. Plus, Filament adalah open source dan gratis, jadi cocok banget untuk project dengan budget terbatas tapi tetap pengen hasil yang professional.

Instalasi Filament melalui Composer

Oke, sekarang kita mulai install Filament. Pastikan kamu masih berada di root directory project Laravel kamu, kemudian jalankan command composer berikut:

composer require filament/filament

Command ini akan download dan install Filament beserta semua dependencies yang dibutuhkan. Proses ini mungkin butuh beberapa menit tergantung kecepatan internet kamu. Filament akan secara otomatis register service provider yang dibutuhkan thanks to Laravel's package auto-discovery.

Setelah installation selesai, kamu bisa verify apakah Filament sudah terinstall dengan benar dengan mengecek apakah package ada di composer.json:

{
    "require": {
        "filament/filament": "^3.0",
        // other dependencies...
    }
}

Publishing Filament Assets

Setelah package terinstall, kita perlu publish assets yang dibutuhkan Filament:

php artisan filament:install --panels

Command ini akan:

  • Publish konfigurasi Filament
  • Setup routing untuk admin panel
  • Copy assets seperti CSS dan JavaScript files
  • Create basic panel configuration

Kamu akan melihat output yang menunjukkan file-file mana saja yang di-publish. Biasanya akan ada beberapa config files dan asset files yang dicopy ke project kamu.

Mengecek Instalasi Filament

Untuk memastikan Filament sudah terinstall dengan benar, coba jalankan development server:

php artisan serve

Kemudian buka browser dan navigasi ke http://localhost:8000/admin. Kamu akan melihat halaman login Filament. Kalau halaman ini muncul, berarti instalasi sudah berhasil! Tapi tentu saja kamu belum bisa login karena belum ada user admin.

Membuat User Admin dengan Artisan Command

Sekarang saatnya membuat user admin pertama. Filament provide artisan command yang sangat convenient untuk ini:

php artisan make:filament-user

Command ini akan menjalankan interactive prompt yang meminta kamu memasukkan detail untuk admin user:

 Name:
 > Admin User

 Email address:
 > [email protected]

 Password:
 >

 Confirm password:
 >

SUCCESS  User BWA created successfully.

Masukkan nama, email, dan password sesuai keinginan kamu. Pastikan password cukup strong karena ini akan jadi admin utama yang punya akses penuh ke sistem.

Understanding User Model untuk Filament

Secara default, Filament menggunakan User model bawaan Laravel yang ada di app/Models/User.php. Model ini sudah compatible dengan Filament out of the box, tapi kita bisa customize sesuai kebutuhan.

Buka file app/Models/User.php dan pastikan strukturnya seperti ini:

<?php

namespace App\\Models;

use Filament\\Models\\Contracts\\FilamentUser;
use Filament\\Panel;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Notifications\\Notifiable;
use Laravel\\Sanctum\\HasApiTokens;

class User extends Authenticatable implements FilamentUser
{
    use HasApiTokens, HasFactory, Notifiable;

    protected $fillable = [
        'name',
        'email',
        'password',
        'is_admin',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'password' => 'hashed',
        'is_admin' => 'boolean',
    ];

    public function canAccessPanel(Panel $panel): bool
    {
        return $this->is_admin ?? false;
    }
}

Menambahkan Field is_admin ke User Table

Untuk memberikan kontrol akses yang lebih baik, kita perlu menambahkan field is_admin ke tabel users. Buat migration baru:

php artisan make:migration add_is_admin_to_users_table

Edit file migration yang baru dibuat:

<?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->boolean('is_admin')->default(false);
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('is_admin');
        });
    }
};

Jalankan migration:

php artisan migrate

Mengatur User Pertama sebagai Admin

Setelah migration selesai, kita perlu update user yang tadi dibuat agar punya status admin. Buka Tinker:

php artisan tinker

Kemudian jalankan command berikut untuk set user pertama sebagai admin:

$user = User::where('email', '[email protected]')->first();
$user->is_admin = true;
$user->save();

Ganti email sesuai dengan yang kamu gunakan saat membuat user tadi.

Konfigurasi Panel Filament

Filament menggunakan sistem panel yang bisa dikustomisasi. File konfigurasi utama ada di app/Providers/Filament/AdminPanelProvider.php. Buka file ini dan customize sesuai kebutuhan:

<?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('Booking Event Ticket')
            ->brandLogo(asset('images/logo.png'))
            ->favicon(asset('images/favicon.ico'))
            ->colors([
                'primary' => Color::Blue,
            ])
            ->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,
                Widgets\\FilamentInfoWidget::class,
            ])
            ->middleware([
                EncryptCookies::class,
                AddQueuedCookiesToResponse::class,
                StartSession::class,
                AuthenticateSession::class,
                ShareErrorsFromSession::class,
                VerifyCsrfToken::class,
                SubstituteBindings::class,
                DisableBladeIconComponents::class,
                DispatchServingFilamentEvent::class,
            ])
            ->authMiddleware([
                Authenticate::class,
            ]);
    }
}

Testing Login Admin

Sekarang saatnya test apakah setup admin sudah berhasil. Jalankan development server kalau belum:

php artisan serve

Buka browser dan navigasi ke http://localhost:8000/admin. Kamu akan melihat halaman login Filament yang clean dan modern. Masukkan email dan password admin yang tadi kamu buat.

Kalau berhasil login, kamu akan diarahkan ke dashboard Filament yang menampilkan berbagai widget dan navigation menu. Selamat! Admin panel kamu sudah ready to use.

Kustomisasi Dashboard

Untuk membuat dashboard lebih personal, kita bisa customize tampilan dan branding. Edit kembali AdminPanelProvider.php dan tambahkan beberapa customization:

public function panel(Panel $panel): Panel
{
    return $panel
        ->default()
        ->id('admin')
        ->path('admin')
        ->login()
        ->brandName('Event Ticket Admin')
        ->brandLogo(asset('images/logo.png'))
        ->brandLogoHeight('2rem')
        ->favicon(asset('images/favicon.ico'))
        ->colors([
            'primary' => Color::Indigo,
            'gray' => Color::Slate,
        ])
        ->font('Inter')
        ->maxContentWidth('full')
        ->sidebarCollapsibleOnDesktop()
        // ... rest of configuration

Menambahkan Multiple Admin Users

Kalau kamu perlu menambahkan admin user lain, ada beberapa cara yang bisa dilakukan. Cara termudah adalah menggunakan Tinker:

php artisan tinker

Kemudian create user baru:

$admin = User::create([
    'name' => 'Second Admin',
    'email' => '[email protected]',
    'password' => bcrypt('password123'),
    'is_admin' => true,
]);

Atau kamu bisa menggunakan command make:filament-user lagi, kemudian update status admin-nya via Tinker seperti sebelumnya.

Troubleshooting Common Issues

Kalau kamu mengalami error saat login atau akses admin panel, coba check beberapa hal berikut:

Pastikan file .env sudah ter-configure dengan benar, terutama database connection dan APP_KEY. Kalau APP_KEY kosong, generate dengan:

php artisan key:generate

Check apakah user yang kamu buat sudah benar-benar ada di database dan field is_admin sudah di-set ke true:

php artisan tinker
User::where('email', '[email protected]')->first();

Kalau halaman admin tidak muncul, pastikan routing sudah ter-register dengan benar. Coba clear cache:

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

Security Considerations

Untuk production environment, ada beberapa hal penting yang perlu diperhatikan:

Jangan pernah gunakan password yang weak untuk admin user. Gunakan password yang complex dan unique.

Consider menggunakan two-factor authentication kalau Filament project kamu akan di-deploy ke production.

Set permission yang proper untuk file dan folder, especially untuk storage dan bootstrap/cache directories.

Pastikan firewall dan security measures lain sudah di-setup dengan benar di server production.

Next Steps

Memahami Konsep Filament Resources

Sebelum kita mulai membuat resource, mari pahami dulu apa itu Filament Resource dan bagaimana cara kerjanya. Resource di Filament adalah class yang bertanggung jawab untuk mengelola CRUD operations dari sebuah model. Setiap resource akan generate halaman untuk listing data, form untuk create/edit, dan detail view untuk setiap record.

Yang bikin Filament powerful adalah kemampuannya untuk auto-generate interface berdasarkan struktur model kita, tapi tetap memberikan flexibility untuk customize sesuai kebutuhan. Dengan pendekatan declarative, kita cukup define fields dan behavior yang diinginkan, dan Filament akan handle semua kompleksitas UI dan logic di belakangnya.

Membuat CategoryResource

Mari kita mulai dengan resource yang paling sederhana dulu, yaitu CategoryResource. Jalankan artisan command berikut:

php artisan make:filament-resource Category --generate

Flag --generate akan secara otomatis generate form dan table berdasarkan structure model Category. Command ini akan membuat file baru di app/Filament/Resources/CategoryResource.php.

Buka file yang baru dibuat dan customize sesuai kebutuhan:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\CategoryResource\\Pages;
use App\\Models\\Category;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Database\\Eloquent\\SoftDeletingScope;
use Illuminate\\Support\\Str;

class CategoryResource extends Resource
{
    protected static ?string $model = Category::class;

    protected static ?string $navigationIcon = 'heroicon-o-tag';

    protected static ?string $navigationGroup = 'Event Management';

    protected static ?int $navigationSort = 1;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\TextInput::make('name')
                    ->required()
                    ->maxLength(255)
                    ->live(onBlur: true)
                    ->afterStateUpdated(fn (string $context, $state, callable $set) =>
                        $context === 'create' ? $set('slug', Str::slug($state)) : null
                    ),

                Forms\\Components\\TextInput::make('slug')
                    ->required()
                    ->maxLength(255)
                    ->unique(Category::class, 'slug', ignoreRecord: true),

                Forms\\Components\\Textarea::make('description')
                    ->columnSpanFull()
                    ->rows(4),

                Forms\\Components\\TextInput::make('icon')
                    ->maxLength(255)
                    ->placeholder('heroicon-o-star'),

                Forms\\Components\\Toggle::make('is_active')
                    ->default(true),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('name')
                    ->searchable()
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('slug')
                    ->searchable()
                    ->copyable()
                    ->copyMessage('Slug copied!')
                    ->color('gray'),

                Tables\\Columns\\TextColumn::make('products_count')
                    ->counts('products')
                    ->label('Products')
                    ->badge()
                    ->color('success'),

                Tables\\Columns\\IconColumn::make('is_active')
                    ->boolean()
                    ->label('Active'),

                Tables\\Columns\\TextColumn::make('created_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                Tables\\Filters\\TernaryFilter::make('is_active')
                    ->label('Active Status')
                    ->boolean()
                    ->trueLabel('Active only')
                    ->falseLabel('Inactive only')
                    ->native(false),
            ])
            ->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\\ListCategories::route('/'),
            'create' => Pages\\CreateCategory::route('/create'),
            'edit' => Pages\\EditCategory::route('/{record}/edit'),
        ];
    }
}

Membuat ProductResource

Sekarang kita buat resource untuk Product yang lebih complex:

php artisan make:filament-resource Product --generate

Edit file app/Filament/Resources/ProductResource.php:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\ProductResource\\Pages;
use App\\Models\\Product;
use App\\Models\\Category;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Support\\Str;

class ProductResource extends Resource
{
    protected static ?string $model = Product::class;

    protected static ?string $navigationIcon = 'heroicon-o-ticket';

    protected static ?string $navigationGroup = 'Event Management';

    protected static ?int $navigationSort = 2;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Basic Information')
                    ->schema([
                        Forms\\Components\\Select::make('category_id')
                            ->relationship('category', 'name')
                            ->required()
                            ->createOptionForm([
                                Forms\\Components\\TextInput::make('name')
                                    ->required(),
                                Forms\\Components\\TextInput::make('slug')
                                    ->required(),
                                Forms\\Components\\Textarea::make('description'),
                            ]),

                        Forms\\Components\\TextInput::make('name')
                            ->required()
                            ->maxLength(255)
                            ->live(onBlur: true)
                            ->afterStateUpdated(fn (string $context, $state, callable $set) =>
                                $context === 'create' ? $set('slug', Str::slug($state)) : null
                            ),

                        Forms\\Components\\TextInput::make('slug')
                            ->required()
                            ->maxLength(255)
                            ->unique(Product::class, 'slug', ignoreRecord: true),

                        Forms\\Components\\Select::make('status')
                            ->options([
                                'draft' => 'Draft',
                                'published' => 'Published',
                                'sold_out' => 'Sold Out',
                                'cancelled' => 'Cancelled',
                            ])
                            ->default('draft')
                            ->required(),

                        Forms\\Components\\Toggle::make('is_featured')
                            ->default(false),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Content')
                    ->schema([
                        Forms\\Components\\Textarea::make('short_description')
                            ->maxLength(500)
                            ->rows(3),

                        Forms\\Components\\RichEditor::make('description')
                            ->required()
                            ->columnSpanFull(),
                    ]),

                Forms\\Components\\Section::make('Media')
                    ->schema([
                        Forms\\Components\\FileUpload::make('featured_image')
                            ->image()
                            ->directory('products')
                            ->visibility('public'),

                        Forms\\Components\\FileUpload::make('gallery')
                            ->image()
                            ->multiple()
                            ->directory('products/gallery')
                            ->visibility('public')
                            ->reorderable(),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Pricing & Stock')
                    ->schema([
                        Forms\\Components\\TextInput::make('price')
                            ->required()
                            ->numeric()
                            ->prefix('Rp'),

                        Forms\\Components\\TextInput::make('discount_price')
                            ->numeric()
                            ->prefix('Rp')
                            ->lt('price'),

                        Forms\\Components\\TextInput::make('stock')
                            ->required()
                            ->numeric()
                            ->default(0),

                        Forms\\Components\\TextInput::make('views')
                            ->numeric()
                            ->default(0)
                            ->disabled(),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Event Details')
                    ->schema([
                        Forms\\Components\\DateTimePicker::make('event_date')
                            ->required()
                            ->native(false),

                        Forms\\Components\\TimePicker::make('event_time')
                            ->required(),

                        Forms\\Components\\TextInput::make('event_location')
                            ->required()
                            ->maxLength(255),

                        Forms\\Components\\KeyValue::make('event_details')
                            ->keyLabel('Detail')
                            ->valueLabel('Information')
                            ->columnSpanFull(),
                    ])
                    ->columns(2),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\ImageColumn::make('featured_image')
                    ->circular(),

                Tables\\Columns\\TextColumn::make('name')
                    ->searchable()
                    ->sortable()
                    ->limit(30),

                Tables\\Columns\\TextColumn::make('category.name')
                    ->badge()
                    ->color('primary'),

                Tables\\Columns\\TextColumn::make('price')
                    ->money('IDR')
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('stock')
                    ->numeric()
                    ->sortable()
                    ->color(fn (string $state): string => match (true) {
                        $state > 100 => 'success',
                        $state > 10 => 'warning',
                        default => 'danger',
                    }),

                Tables\\Columns\\TextColumn::make('event_date')
                    ->date()
                    ->sortable(),

                Tables\\Columns\\SelectColumn::make('status')
                    ->options([
                        'draft' => 'Draft',
                        'published' => 'Published',
                        'sold_out' => 'Sold Out',
                        'cancelled' => 'Cancelled',
                    ]),

                Tables\\Columns\\IconColumn::make('is_featured')
                    ->boolean()
                    ->label('Featured'),

                Tables\\Columns\\TextColumn::make('orders_count')
                    ->counts('orders')
                    ->label('Orders')
                    ->badge()
                    ->color('success'),
            ])
            ->filters([
                Tables\\Filters\\SelectFilter::make('category')
                    ->relationship('category', 'name'),

                Tables\\Filters\\SelectFilter::make('status')
                    ->options([
                        'draft' => 'Draft',
                        'published' => 'Published',
                        'sold_out' => 'Sold Out',
                        'cancelled' => 'Cancelled',
                    ]),

                Tables\\Filters\\TernaryFilter::make('is_featured')
                    ->label('Featured'),

                Tables\\Filters\\Filter::make('event_date')
                    ->form([
                        Forms\\Components\\DatePicker::make('event_from'),
                        Forms\\Components\\DatePicker::make('event_until'),
                    ])
                    ->query(function (Builder $query, array $data): Builder {
                        return $query
                            ->when(
                                $data['event_from'],
                                fn (Builder $query, $date): Builder => $query->whereDate('event_date', '>=', $date),
                            )
                            ->when(
                                $data['event_until'],
                                fn (Builder $query, $date): Builder => $query->whereDate('event_date', '<=', $date),
                            );
                    }),
            ])
            ->actions([
                Tables\\Actions\\ViewAction::make(),
                Tables\\Actions\\EditAction::make(),
                Tables\\Actions\\DeleteAction::make(),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make(),
                ]),
            ])
            ->defaultSort('created_at', 'desc');
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\\ListProducts::route('/'),
            'create' => Pages\\CreateProduct::route('/create'),
            'view' => Pages\\ViewProduct::route('/{record}'),
            'edit' => Pages\\EditProduct::route('/{record}/edit'),
        ];
    }
}

Membuat CustomerResource

Sekarang kita buat resource untuk manage customer data:

php artisan make:filament-resource Customer --generate

Edit file app/Filament/Resources/CustomerResource.php:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\CustomerResource\\Pages;
use App\\Models\\Customer;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;

class CustomerResource extends Resource
{
    protected static ?string $model = Customer::class;

    protected static ?string $navigationIcon = 'heroicon-o-users';

    protected static ?string $navigationGroup = 'Customer Management';

    protected static ?int $navigationSort = 1;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Personal Information')
                    ->schema([
                        Forms\\Components\\TextInput::make('name')
                            ->required()
                            ->maxLength(255),

                        Forms\\Components\\TextInput::make('email')
                            ->email()
                            ->required()
                            ->unique(Customer::class, 'email', ignoreRecord: true)
                            ->maxLength(255),

                        Forms\\Components\\TextInput::make('phone')
                            ->tel()
                            ->maxLength(255),

                        Forms\\Components\\DatePicker::make('birth_date')
                            ->native(false),

                        Forms\\Components\\Select::make('gender')
                            ->options([
                                'male' => 'Male',
                                'female' => 'Female',
                                'other' => 'Other',
                            ])
                            ->native(false),

                        Forms\\Components\\Toggle::make('is_active')
                            ->default(true),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Address Information')
                    ->schema([
                        Forms\\Components\\Textarea::make('address')
                            ->rows(3)
                            ->columnSpanFull(),

                        Forms\\Components\\TextInput::make('city')
                            ->maxLength(255),

                        Forms\\Components\\TextInput::make('province')
                            ->maxLength(255),

                        Forms\\Components\\TextInput::make('postal_code')
                            ->maxLength(255),
                    ])
                    ->columns(3),

                Forms\\Components\\Section::make('Account Information')
                    ->schema([
                        Forms\\Components\\TextInput::make('password')
                            ->password()
                            ->dehydrateStateUsing(fn ($state) => filled($state) ? bcrypt($state) : null)
                            ->dehydrated(fn ($state) => filled($state))
                            ->required(fn (string $context): bool => $context === 'create'),

                        Forms\\Components\\DateTimePicker::make('last_login_at')
                            ->disabled(),

                        Forms\\Components\\DateTimePicker::make('email_verified_at')
                            ->disabled(),
                    ])
                    ->columns(2),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('name')
                    ->searchable()
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('email')
                    ->searchable()
                    ->copyable()
                    ->copyMessage('Email copied!')
                    ->icon('heroicon-m-envelope'),

                Tables\\Columns\\TextColumn::make('phone')
                    ->searchable()
                    ->icon('heroicon-m-phone'),

                Tables\\Columns\\TextColumn::make('city')
                    ->searchable()
                    ->toggleable(),

                Tables\\Columns\\TextColumn::make('orders_count')
                    ->counts('orders')
                    ->label('Total Orders')
                    ->badge()
                    ->color('success'),

                Tables\\Columns\\IconColumn::make('is_active')
                    ->boolean()
                    ->label('Active'),

                Tables\\Columns\\TextColumn::make('last_login_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(),

                Tables\\Columns\\TextColumn::make('created_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                Tables\\Filters\\TernaryFilter::make('is_active')
                    ->label('Active Status'),

                Tables\\Filters\\TernaryFilter::make('email_verified_at')
                    ->label('Email Verified')
                    ->nullable(),

                Tables\\Filters\\SelectFilter::make('city')
                    ->options(fn (): array =>
                        Customer::whereNotNull('city')
                            ->distinct()
                            ->pluck('city', 'city')
                            ->toArray()
                    ),
            ])
            ->actions([
                Tables\\Actions\\ViewAction::make(),
                Tables\\Actions\\EditAction::make(),
                Tables\\Actions\\DeleteAction::make(),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make(),
                ]),
            ])
            ->defaultSort('created_at', 'desc');
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\\ListCustomers::route('/'),
            'create' => Pages\\CreateCustomer::route('/create'),
            'view' => Pages\\ViewCustomer::route('/{record}'),
            'edit' => Pages\\EditCustomer::route('/{record}/edit'),
        ];
    }
}

Membuat OrderResource

Terakhir, kita buat resource untuk mengelola order data:

php artisan make:filament-resource Order --generate

Edit file app/Filament/Resources/OrderResource.php:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\OrderResource\\Pages;
use App\\Models\\Order;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;

class OrderResource extends Resource
{
    protected static ?string $model = Order::class;

    protected static ?string $navigationIcon = 'heroicon-o-shopping-cart';

    protected static ?string $navigationGroup = 'Order Management';

    protected static ?int $navigationSort = 1;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Order Information')
                    ->schema([
                        Forms\\Components\\TextInput::make('order_number')
                            ->required()
                            ->unique(Order::class, 'order_number', ignoreRecord: true)
                            ->default(fn () => 'ORD-' . date('Ymd') . '-' . str_pad(mt_rand(1, 9999), 4, '0', STR_PAD_LEFT)),

                        Forms\\Components\\Select::make('customer_id')
                            ->relationship('customer', 'name')
                            ->searchable()
                            ->preload()
                            ->required()
                            ->createOptionForm([
                                Forms\\Components\\TextInput::make('name')->required(),
                                Forms\\Components\\TextInput::make('email')->email()->required(),
                                Forms\\Components\\TextInput::make('phone'),
                            ]),

                        Forms\\Components\\Select::make('product_id')
                            ->relationship('product', 'name')
                            ->searchable()
                            ->preload()
                            ->required()
                            ->reactive()
                            ->afterStateUpdated(function ($state, callable $set) {
                                if ($state) {
                                    $product = \\App\\Models\\Product::find($state);
                                    if ($product) {
                                        $set('unit_price', $product->current_price);
                                    }
                                }
                            }),

                        Forms\\Components\\TextInput::make('quantity')
                            ->required()
                            ->numeric()
                            ->default(1)
                            ->reactive()
                            ->afterStateUpdated(function ($state, callable $get, callable $set) {
                                $unitPrice = $get('unit_price');
                                if ($unitPrice && $state) {
                                    $totalPrice = $unitPrice * $state;
                                    $discountAmount = $get('discount_amount') ?? 0;
                                    $set('total_price', $totalPrice);
                                    $set('final_amount', $totalPrice - $discountAmount);
                                }
                            }),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Pricing')
                    ->schema([
                        Forms\\Components\\TextInput::make('unit_price')
                            ->required()
                            ->numeric()
                            ->prefix('Rp')
                            ->reactive()
                            ->afterStateUpdated(function ($state, callable $get, callable $set) {
                                $quantity = $get('quantity') ?? 1;
                                if ($state) {
                                    $totalPrice = $state * $quantity;
                                    $discountAmount = $get('discount_amount') ?? 0;
                                    $set('total_price', $totalPrice);
                                    $set('final_amount', $totalPrice - $discountAmount);
                                }
                            }),

                        Forms\\Components\\TextInput::make('discount_amount')
                            ->numeric()
                            ->prefix('Rp')
                            ->default(0)
                            ->reactive()
                            ->afterStateUpdated(function ($state, callable $get, callable $set) {
                                $totalPrice = $get('total_price') ?? 0;
                                $set('final_amount', $totalPrice - ($state ?? 0));
                            }),

                        Forms\\Components\\TextInput::make('total_price')
                            ->required()
                            ->numeric()
                            ->prefix('Rp')
                            ->disabled(),

                        Forms\\Components\\TextInput::make('final_amount')
                            ->required()
                            ->numeric()
                            ->prefix('Rp')
                            ->disabled(),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Status & Payment')
                    ->schema([
                        Forms\\Components\\Select::make('status')
                            ->options([
                                'pending' => 'Pending',
                                'paid' => 'Paid',
                                'cancelled' => 'Cancelled',
                                'refunded' => 'Refunded',
                            ])
                            ->default('pending')
                            ->required(),

                        Forms\\Components\\Select::make('payment_status')
                            ->options([
                                'unpaid' => 'Unpaid',
                                'paid' => 'Paid',
                                'partial' => 'Partial',
                                'refunded' => 'Refunded',
                            ])
                            ->default('unpaid')
                            ->required(),

                        Forms\\Components\\TextInput::make('payment_method')
                            ->maxLength(255),

                        Forms\\Components\\TextInput::make('payment_reference')
                            ->maxLength(255),

                        Forms\\Components\\DateTimePicker::make('payment_date')
                            ->native(false),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Additional Information')
                    ->schema([
                        Forms\\Components\\KeyValue::make('customer_details')
                            ->keyLabel('Field')
                            ->valueLabel('Value'),

                        Forms\\Components\\KeyValue::make('payment_details')
                            ->keyLabel('Field')
                            ->valueLabel('Value'),

                        Forms\\Components\\Textarea::make('notes')
                            ->rows(3)
                            ->columnSpanFull(),
                    ]),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('order_number')
                    ->searchable()
                    ->copyable()
                    ->copyMessage('Order number copied!')
                    ->weight('bold'),

                Tables\\Columns\\TextColumn::make('customer.name')
                    ->searchable()
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('product.name')
                    ->searchable()
                    ->limit(30),

                Tables\\Columns\\TextColumn::make('quantity')
                    ->numeric()
                    ->alignCenter(),

                Tables\\Columns\\TextColumn::make('final_amount')
                    ->money('IDR')
                    ->sortable()
                    ->weight('bold'),

                Tables\\Columns\\SelectColumn::make('status')
                    ->options([
                        'pending' => 'Pending',
                        'paid' => 'Paid',
                        'cancelled' => 'Cancelled',
                        'refunded' => 'Refunded',
                    ])
                    ->selectablePlaceholder(false),

                Tables\\Columns\\SelectColumn::make('payment_status')
                    ->options([
                        'unpaid' => 'Unpaid',
                        'paid' => 'Paid',
                        'partial' => 'Partial',
                        'refunded' => 'Refunded',
                    ])
                    ->selectablePlaceholder(false),

                Tables\\Columns\\TextColumn::make('payment_date')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(),

                Tables\\Columns\\TextColumn::make('created_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(),
            ])
            ->filters([
                Tables\\Filters\\SelectFilter::make('status')
                    ->options([
                        'pending' => 'Pending',
                        'paid' => 'Paid',
                        'cancelled' => 'Cancelled',
                        'refunded' => 'Refunded',
                    ]),

                Tables\\Filters\\SelectFilter::make('payment_status')
                    ->options([
                        'unpaid' => 'Unpaid',
                        'paid' => 'Paid',
                        'partial' => 'Partial',
                        'refunded' => 'Refunded',
                    ]),

                Tables\\Filters\\Filter::make('created_at')
                    ->form([
                        Forms\\Components\\DatePicker::make('created_from'),
                        Forms\\Components\\DatePicker::make('created_until'),
                    ])
                    ->query(function (Builder $query, array $data): Builder {
                        return $query
                            ->when(
                                $data['created_from'],
                                fn (Builder $query, $date): Builder => $query->whereDate('created_at', '>=', $date),
                            )
                            ->when(
                                $data['created_until'],
                                fn (Builder $query, $date): Builder => $query->whereDate('created_at', '<=', $date),
                            );
                    }),
            ])
            ->actions([
                Tables\\Actions\\ViewAction::make(),
                Tables\\Actions\\EditAction::make(),
                Tables\\Actions\\DeleteAction::make(),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make(),
                ]),
            ])
            ->defaultSort('created_at', 'desc');
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\\ListOrders::route('/'),
            'create' => Pages\\CreateOrder::route('/create'),
            'view' => Pages\\ViewOrder::route('/{record}'),
            'edit' => Pages\\EditOrder::route('/{record}/edit'),
        ];
    }
}

Customizing Navigation Group

Untuk mengorganisir navigation menu dengan lebih baik, kita perlu update AdminPanelProvider untuk grouping navigation items. Edit file app/Providers/Filament/AdminPanelProvider.php:

public function panel(Panel $panel): Panel
{
    return $panel
        ->default()
        ->id('admin')
        ->path('admin')
        ->login()
        ->brandName('Event Ticket Admin')
        ->navigationGroups([
            'Event Management',
            'Customer Management',
            'Order Management',
            'Reports',
            'Settings',
        ])
        // ... rest of configuration
}

Testing CRUD Operations

Sekarang semua resource sudah dibuat, saatnya test functionality-nya. Akses admin panel di http://localhost:8000/admin dan login dengan admin user yang sudah dibuat sebelumnya.

Kamu akan melihat navigation sidebar yang terorganisir dengan grouping yang sudah kita set. Coba create beberapa kategori terlebih dahulu, kemudian product, customer, dan order untuk memastikan semuanya berfungsi dengan baik.

Advanced Features dan Tips

Setiap resource yang kita buat sudah include beberapa advanced features seperti:

Search functionality yang memungkinkan admin untuk quickly find specific records Sorting capability untuk mengorganisir data berdasarkan kolom tertentu Filtering options untuk narrow down data based on specific criteria Bulk actions untuk perform operations on multiple records sekaligus Relationship handling yang memudahkan management data yang saling terkait

Untuk optimize performance, terutama kalau data sudah banyak, consider menggunakan eager loading di resource atau implement pagination yang sesuai. Filament sudah handle most of these automatically, tapi untuk specific cases mungkin perlu custom implementation.

Memahami Authentication Options di Laravel

Sebelum kita mulai implement authentication, ada baiknya kita pahami dulu pilihan-pilihan yang tersedia. Laravel menyediakan beberapa starter kit untuk authentication: Laravel Breeze, Laravel Jetstream, dan Laravel Fortify. Untuk project booking event ticket kita, Laravel Breeze akan jadi pilihan yang perfect karena lightweight tapi tetap feature-complete.

Laravel Breeze provide simple authentication scaffolding termasuk login, registration, password reset, dan email verification. Yang bikin menarik, Breeze juga support berbagai frontend stack seperti Blade, React, Vue, atau bahkan Inertia.js. Untuk tutorial kali ini, kita akan pakai Blade template karena paling straightforward dan terintegrasi dengan baik sama Filament.

Instalasi Laravel Breeze

Mari kita mulai dengan install Laravel Breeze. Pastikan kamu masih berada di root directory project Laravel, kemudian jalankan composer command:

composer require laravel/breeze --dev

Setelah package terinstall, kita perlu publish authentication views, routes, dan controllers dengan artisan command:

php artisan breeze:install blade

Command ini akan generate beberapa file penting:

  • Authentication views di resources/views/auth/
  • Authentication controllers di app/Http/Controllers/Auth/
  • Routes untuk authentication di routes/auth.php
  • Middleware untuk authentication

Setelah instalasi selesai, install dependencies dan compile assets:

npm install && npm run dev

Menjalankan Migration untuk Authentication

Laravel Breeze memerlukan beberapa tabel database untuk authentication. Jalankan migration untuk create tabel yang diperlukan:

php artisan migrate

Migration ini akan create tabel users, password_reset_tokens, sessions, dan failed_jobs yang essential untuk authentication system.

Konfigurasi User Model untuk Filament

Sekarang kita perlu update User model agar compatible dengan both Breeze dan Filament. Edit file app/Models/User.php:

<?php

namespace App\\Models;

use Filament\\Models\\Contracts\\FilamentUser;
use Filament\\Panel;
use Illuminate\\Contracts\\Auth\\MustVerifyEmail;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Notifications\\Notifiable;
use Laravel\\Sanctum\\HasApiTokens;

class User extends Authenticatable implements FilamentUser, MustVerifyEmail
{
    use HasApiTokens, HasFactory, Notifiable;

    protected $fillable = [
        'name',
        'email',
        'password',
        'role',
        'is_admin',
        'email_verified_at',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'password' => 'hashed',
        'is_admin' => 'boolean',
    ];

    public function canAccessPanel(Panel $panel): bool
    {
        return $this->is_admin === true && $this->hasVerifiedEmail();
    }

    public function isAdmin(): bool
    {
        return $this->is_admin === true;
    }

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

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

Menambahkan Fields Role dan is_admin ke User Table

Kita perlu menambahkan field tambahan untuk manage access control. Buat migration baru:

php artisan make:migration add_role_and_admin_fields_to_users_table

Edit file migration yang baru dibuat:

<?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('role')->default('user');
            $table->boolean('is_admin')->default(false);
            $table->timestamp('last_login_at')->nullable();
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn(['role', 'is_admin', 'last_login_at']);
        });
    }
};

Jalankan migration:

php artisan migrate

Customizing Registration Controller

Kita perlu customize registration controller untuk automatically set role dan handle admin privileges. Edit file app/Http/Controllers/Auth/RegisteredUserController.php:

<?php

namespace App\\Http\\Controllers\\Auth;

use App\\Http\\Controllers\\Controller;
use App\\Models\\User;
use App\\Providers\\RouteServiceProvider;
use Illuminate\\Auth\\Events\\Registered;
use Illuminate\\Http\\RedirectResponse;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Auth;
use Illuminate\\Support\\Facades\\Hash;
use Illuminate\\Validation\\Rules;
use Illuminate\\View\\View;

class RegisteredUserController extends Controller
{
    public function create(): View
    {
        return view('auth.register');
    }

    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:'.User::class],
            'password' => ['required', 'confirmed', Rules\\Password::defaults()],
        ]);

        // Check if this is the first user (auto-make admin)
        $isFirstUser = User::count() === 0;

        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
            'role' => $isFirstUser ? 'super_admin' : 'user',
            'is_admin' => $isFirstUser,
        ]);

        event(new Registered($user));

        Auth::login($user);

        return redirect(RouteServiceProvider::HOME);
    }
}

Customizing Login Controller untuk Track Last Login

Update login controller untuk track kapan user terakhir login. Edit file app/Http/Controllers/Auth/AuthenticatedSessionController.php:

<?php

namespace App\\Http\\Controllers\\Auth;

use App\\Http\\Controllers\\Controller;
use App\\Http\\Requests\\Auth\\LoginRequest;
use App\\Providers\\RouteServiceProvider;
use Illuminate\\Http\\RedirectResponse;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Auth;
use Illuminate\\View\\View;

class AuthenticatedSessionController extends Controller
{
    public function create(): View
    {
        return view('auth.login');
    }

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

        $request->session()->regenerate();

        // Update last login timestamp
        $user = Auth::user();
        $user->update(['last_login_at' => now()]);

        // Redirect admin users to admin panel
        if ($user->isAdmin()) {
            return redirect()->intended('/admin');
        }

        return redirect()->intended(RouteServiceProvider::HOME);
    }

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

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

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

        return redirect('/');
    }
}

Membuat Custom Middleware untuk Admin Access

Buat middleware khusus untuk protect admin routes. Generate middleware baru:

php artisan make:middleware EnsureUserIsAdmin

Edit file app/Http/Middleware/EnsureUserIsAdmin.php:

<?php

namespace App\\Http\\Middleware;

use Closure;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Auth;
use Symfony\\Component\\HttpFoundation\\Response;

class EnsureUserIsAdmin
{
    public function handle(Request $request, Closure $next): Response
    {
        if (!Auth::check()) {
            return redirect()->route('login')->with('error', 'Please login to access this area.');
        }

        if (!Auth::user()->isAdmin()) {
            abort(403, 'Access denied. Admin privileges required.');
        }

        if (!Auth::user()->hasVerifiedEmail()) {
            return redirect()->route('verification.notice')
                ->with('error', 'Please verify your email before accessing admin area.');
        }

        return $next($request);
    }
}

Register middleware di app/Http/Kernel.php:

protected $middlewareAliases = [
    // ... other middleware
    'admin' => \\App\\Http\\Middleware\\EnsureUserIsAdmin::class,
];

Customizing Authentication Views

Mari kita customize authentication views agar sesuai dengan branding website booking event ticket. Edit file resources/views/layouts/guest.blade.php:

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

        <title>{{ config('app.name', 'Laravel') }}</title>

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

        <!-- Scripts -->
        @vite(['resources/css/app.css', 'resources/js/app.js'])
    </head>
    <body class="font-sans text-gray-900 antialiased">
        <div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gradient-to-br from-blue-50 to-indigo-100">
            <div>
                <a href="/">
                    <x-application-logo class="w-20 h-20 fill-current text-indigo-600" />
                </a>
                <h1 class="mt-4 text-2xl font-bold text-center text-gray-800">Event Ticket Booking</h1>
            </div>

            <div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white shadow-md overflow-hidden sm:rounded-lg border border-gray-200">
                {{ $slot }}
            </div>
        </div>
    </body>
</html>

Update login form di resources/views/auth/login.blade.php:

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

    <div class="mb-6 text-center">
        <h2 class="text-xl font-semibold text-gray-800">Sign In</h2>
        <p class="text-sm text-gray-600 mt-1">Welcome back! Please sign in to your account.</p>
    </div>

    <form method="POST" action="{{ route('login') }}">
        @csrf

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

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

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

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

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

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

            <x-primary-button class="ml-3">
                {{ __('Sign In') }}
            </x-primary-button>
        </div>

        <div class="mt-6 text-center">
            <p class="text-sm text-gray-600">
                Don't have an account?
                <a href="{{ route('register') }}" class="text-indigo-600 hover:text-indigo-900 font-medium">Sign up here</a>
            </p>
        </div>
    </form>
</x-guest-layout>

Update Registration Form

Edit file resources/views/auth/register.blade.php:

<x-guest-layout>
    <div class="mb-6 text-center">
        <h2 class="text-xl font-semibold text-gray-800">Create Account</h2>
        <p class="text-sm text-gray-600 mt-1">Join us to book amazing events!</p>
    </div>

    <form method="POST" action="{{ route('register') }}">
        @csrf

        <!-- Name -->
        <div>
            <x-input-label for="name" :value="__('Full Name')" />
            <x-text-input id="name" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
            <x-input-error :messages="$errors->get('name')" class="mt-2" />
        </div>

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

        <!-- Password -->
        <div class="mt-4">
            <x-input-label for="password" :value="__('Password')" />
            <x-text-input id="password" class="block mt-1 w-full"
                            type="password"
                            name="password"
                            required autocomplete="new-password" />
            <x-input-error :messages="$errors->get('password')" class="mt-2" />
        </div>

        <!-- Confirm Password -->
        <div class="mt-4">
            <x-input-label for="password_confirmation" :value="__('Confirm Password')" />
            <x-text-input id="password_confirmation" class="block mt-1 w-full"
                            type="password"
                            name="password_confirmation" required autocomplete="new-password" />
            <x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
        </div>

        <div class="flex items-center justify-between mt-6">
            <a class="text-sm text-indigo-600 hover:text-indigo-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" href="{{ route('login') }}">
                {{ __('Already have account?') }}
            </a>

            <x-primary-button class="ml-4">
                {{ __('Create Account') }}
            </x-primary-button>
        </div>
    </form>
</x-guest-layout>

Protecting Filament Routes

Update Filament panel configuration untuk ensure proper authentication. Edit app/Providers/Filament/AdminPanelProvider.php:

public function panel(Panel $panel): Panel
{
    return $panel
        ->default()
        ->id('admin')
        ->path('admin')
        ->login()
        ->registration()
        ->emailVerification()
        ->passwordReset()
        ->brandName('Event Ticket Admin')
        ->authGuard('web')
        ->authPasswordBroker('users')
        ->middleware([
            EncryptCookies::class,
            AddQueuedCookiesToResponse::class,
            StartSession::class,
            AuthenticateSession::class,
            ShareErrorsFromSession::class,
            VerifyCsrfToken::class,
            SubstituteBindings::class,
            DisableBladeIconComponents::class,
            DispatchServingFilamentEvent::class,
        ])
        ->authMiddleware([
            Authenticate::class,
        ])
        ->plugins([
            // Add plugins if needed
        ])
        // ... rest of configuration
}

Creating User Management Resource for Filament

Buat resource untuk manage users dari admin panel:

php artisan make:filament-resource User --generate

Edit app/Filament/Resources/UserResource.php:

<?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\\Database\\Eloquent\\Builder;
use Illuminate\\Support\\Facades\\Hash;

class UserResource extends Resource
{
    protected static ?string $model = User::class;

    protected static ?string $navigationIcon = 'heroicon-o-user-group';

    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('User Information')
                    ->schema([
                        Forms\\Components\\TextInput::make('name')
                            ->required()
                            ->maxLength(255),

                        Forms\\Components\\TextInput::make('email')
                            ->email()
                            ->required()
                            ->unique(User::class, 'email', ignoreRecord: true)
                            ->maxLength(255),

                        Forms\\Components\\TextInput::make('password')
                            ->password()
                            ->dehydrateStateUsing(fn ($state) => filled($state) ? Hash::make($state) : null)
                            ->dehydrated(fn ($state) => filled($state))
                            ->required(fn (string $context): bool => $context === 'create')
                            ->maxLength(255),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Permissions')
                    ->schema([
                        Forms\\Components\\Select::make('role')
                            ->options([
                                'user' => 'User',
                                'admin' => 'Admin',
                                'super_admin' => 'Super Admin',
                            ])
                            ->default('user')
                            ->required(),

                        Forms\\Components\\Toggle::make('is_admin')
                            ->label('Admin Access')
                            ->default(false),

                        Forms\\Components\\DateTimePicker::make('email_verified_at')
                            ->label('Email Verified At')
                            ->native(false),
                    ])
                    ->columns(2),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('name')
                    ->searchable()
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('email')
                    ->searchable()
                    ->copyable(),

                Tables\\Columns\\TextColumn::make('role')
                    ->badge()
                    ->color(fn (string $state): string => match ($state) {
                        'super_admin' => 'danger',
                        'admin' => 'warning',
                        'user' => 'primary',
                    }),

                Tables\\Columns\\IconColumn::make('is_admin')
                    ->boolean()
                    ->label('Admin'),

                Tables\\Columns\\IconColumn::make('email_verified_at')
                    ->boolean()
                    ->label('Verified')
                    ->getStateUsing(fn ($record) => !is_null($record->email_verified_at)),

                Tables\\Columns\\TextColumn::make('last_login_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(),

                Tables\\Columns\\TextColumn::make('created_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                Tables\\Filters\\SelectFilter::make('role')
                    ->options([
                        'user' => 'User',
                        'admin' => 'Admin',
                        'super_admin' => 'Super Admin',
                    ]),

                Tables\\Filters\\TernaryFilter::make('is_admin')
                    ->label('Admin Access'),

                Tables\\Filters\\TernaryFilter::make('email_verified_at')
                    ->label('Email Verified')
                    ->nullable(),
            ])
            ->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\\ListUsers::route('/'),
            'create' => Pages\\CreateUser::route('/create'),
            'edit' => Pages\\EditUser::route('/{record}/edit'),
        ];
    }
}

Testing Authentication Flow

Sekarang saatnya test authentication system yang sudah kita setup. Jalankan development server:

php artisan serve

Akses http://localhost:8000/register untuk test registration. Create user baru dan verifikasi bahwa:

  • User pertama otomatis jadi super admin
  • Email verification berfungsi dengan baik
  • Login redirect ke appropriate dashboard

Test juga access control dengan mencoba akses /admin menggunakan:

  • User yang bukan admin (should be denied)
  • User admin tapi email belum verified (should redirect to verification)
  • User admin dengan email verified (should access admin panel)

Security Best Practices

Untuk production environment, pastikan implement beberapa security measures tambahan:

Enable rate limiting untuk login attempts dengan uncomment throttle middleware di routes/web.php:

Route::middleware('throttle:login')->group(function () {
    Route::post('/login', [AuthenticatedSessionController::class, 'store'])
        ->name('login');
});

Consider implement two-factor authentication untuk admin users, especially untuk super admin role.

Setup proper session configuration di config/session.php dan pastikan secure cookies enabled untuk HTTPS environment.

Regular audit user permissions dan remove inactive admin accounts untuk maintain security hygiene.

Dengan authentication system yang robust ini, admin panel kamu sekarang punya layer security yang proper dan ready untuk production deployment!

Memahami Konsep Role dan Permission

Sebelum kita mulai implement Spatie Laravel Permission, mari pahami dulu konsep role-based access control (RBAC) dalam konteks website booking event ticket. Dalam sistem yang kompleks, kita nggak bisa cuma punya admin dan user biasa. Ada berbagai tingkat akses yang dibutuhkan tergantung job responsibility masing-masing.

Misalnya, sales staff cuma perlu akses ke order management untuk handle customer inquiries dan process payments, tapi mereka nggak perlu bisa edit product atau manage user accounts. Product manager butuh full access ke product dan category management, tapi mereka nggak perlu handle financial reports atau user permissions. Dengan Spatie Permission, kita bisa create granular control yang sangat flexible.

Instalasi Spatie Laravel Permission

Mari kita mulai dengan install package Spatie Permission. Jalankan composer command di root directory project:

composer require spatie/laravel-permission

Setelah package terinstall, kita perlu publish migration files:

php artisan vendor:publish --provider="Spatie\\Permission\\PermissionServiceProvider"

Command ini akan create beberapa migration files untuk roles, permissions, dan pivot tables yang dibutuhkan. Jalankan migration untuk create tables:

php artisan migrate

Migration ini akan create tabel roles, permissions, model_has_permissions, model_has_roles, dan role_has_permissions yang akan handle seluruh permission system.

Konfigurasi Spatie Permission

Publish config file untuk customize behavior package:

php artisan config:publish spatie/laravel-permission

Edit file config/permission.php untuk adjust sesuai kebutuhan:

<?php

return [
    'models' => [
        'permission' => Spatie\\Permission\\Models\\Permission::class,
        'role' => Spatie\\Permission\\Models\\Role::class,
    ],

    'table_names' => [
        'roles' => 'roles',
        'permissions' => 'permissions',
        'model_has_permissions' => 'model_has_permissions',
        'model_has_roles' => 'model_has_roles',
        'role_has_permissions' => 'role_has_permissions',
    ],

    'column_names' => [
        'role_foreign_key' => 'role_id',
        'permission_foreign_key' => 'permission_id',
        'model_morph_key' => 'model_id',
        'team_foreign_key' => 'team_id',
    ],

    'register_permission_check_method' => true,
    'teams' => false,
    'use_passport_client_credentials' => false,
    'display_permission_in_exception' => false,
    'display_role_in_exception' => false,
    'enable_wildcard_permission' => false,
    'cache' => [
        'expiration_time' => \\DateInterval::createFromDateString('24 hours'),
        'key' => 'spatie.permission.cache',
        'store' => 'default',
    ],
];

Update User Model untuk Spatie Permission

Edit file app/Models/User.php untuk add Spatie traits:

<?php

namespace App\\Models;

use Filament\\Models\\Contracts\\FilamentUser;
use Filament\\Panel;
use Illuminate\\Contracts\\Auth\\MustVerifyEmail;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Notifications\\Notifiable;
use Laravel\\Sanctum\\HasApiTokens;
use Spatie\\Permission\\Traits\\HasRoles;

class User extends Authenticatable implements FilamentUser, MustVerifyEmail
{
    use HasApiTokens, HasFactory, Notifiable, HasRoles;

    protected $fillable = [
        'name',
        'email',
        'password',
        'is_admin',
        'email_verified_at',
        'last_login_at',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'password' => 'hashed',
        'is_admin' => 'boolean',
        'last_login_at' => 'datetime',
    ];

    public function canAccessPanel(Panel $panel): bool
    {
        return $this->hasAnyRole(['admin', 'product_manager', 'sales_staff'])
               && $this->hasVerifiedEmail();
    }

    public function isAdmin(): bool
    {
        return $this->hasRole('admin');
    }

    public function isSuperAdmin(): bool
    {
        return $this->hasRole('admin') && $this->is_admin === true;
    }

    public function canManageProducts(): bool
    {
        return $this->hasAnyRole(['admin', 'product_manager']);
    }

    public function canManageOrders(): bool
    {
        return $this->hasAnyRole(['admin', 'sales_staff']);
    }

    public function canViewReports(): bool
    {
        return $this->hasRole('admin');
    }

    public function canManageUsers(): bool
    {
        return $this->hasRole('admin');
    }
}

Membuat Seeder untuk Roles dan Permissions

Buat seeder untuk setup initial roles dan permissions:

php artisan make:seeder RolePermissionSeeder

Edit file database/seeders/RolePermissionSeeder.php:

<?php

namespace Database\\Seeders;

use Illuminate\\Database\\Seeder;
use Spatie\\Permission\\Models\\Permission;
use Spatie\\Permission\\Models\\Role;
use App\\Models\\User;

class RolePermissionSeeder extends Seeder
{
    public function run(): void
    {
        // Reset cached roles and permissions
        app()[\\Spatie\\Permission\\PermissionRegistrar::class]->forgetCachedPermissions();

        // Create permissions
        $permissions = [
            // Product permissions
            'view_products',
            'create_products',
            'edit_products',
            'delete_products',
            'publish_products',

            // Category permissions
            'view_categories',
            'create_categories',
            'edit_categories',
            'delete_categories',

            // Order permissions
            'view_orders',
            'create_orders',
            'edit_orders',
            'delete_orders',
            'process_payments',
            'refund_orders',

            // Customer permissions
            'view_customers',
            'create_customers',
            'edit_customers',
            'delete_customers',

            // User management permissions
            'view_users',
            'create_users',
            'edit_users',
            'delete_users',
            'assign_roles',

            // Report permissions
            'view_reports',
            'export_reports',

            // System permissions
            'access_admin_panel',
            'manage_settings',
        ];

        foreach ($permissions as $permission) {
            Permission::create(['name' => $permission]);
        }

        // Create roles and assign permissions

        // Admin role - full access
        $adminRole = Role::create(['name' => 'admin']);
        $adminRole->givePermissionTo(Permission::all());

        // Product Manager role
        $productManagerRole = Role::create(['name' => 'product_manager']);
        $productManagerRole->givePermissionTo([
            'access_admin_panel',
            'view_products',
            'create_products',
            'edit_products',
            'delete_products',
            'publish_products',
            'view_categories',
            'create_categories',
            'edit_categories',
            'delete_categories',
            'view_orders', // Can view orders related to their products
            'view_customers', // Can view customer data for product insights
        ]);

        // Sales Staff role
        $salesStaffRole = Role::create(['name' => 'sales_staff']);
        $salesStaffRole->givePermissionTo([
            'access_admin_panel',
            'view_orders',
            'create_orders',
            'edit_orders',
            'process_payments',
            'refund_orders',
            'view_customers',
            'create_customers',
            'edit_customers',
            'view_products', // Can view products to help customers
        ]);

        // Customer role - for frontend users
        $customerRole = Role::create(['name' => 'customer']);
        $customerRole->givePermissionTo([
            'view_products', // Can browse products
        ]);

        // Create super admin user if doesn't exist
        $adminUser = User::where('email', '[email protected]')->first();
        if (!$adminUser) {
            $adminUser = User::create([
                'name' => 'Super Admin',
                'email' => '[email protected]',
                'password' => bcrypt('password'),
                'is_admin' => true,
                'email_verified_at' => now(),
            ]);
        }
        $adminUser->assignRole('admin');

        $this->command->info('Roles and permissions created successfully!');
    }
}

Jalankan seeder:

php artisan db:seed --class=RolePermissionSeeder

Update DatabaseSeeder untuk Include Role Permission Seeder

Edit file database/seeders/DatabaseSeeder.php:

<?php

namespace Database\\Seeders;

use Illuminate\\Database\\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this->call([
            RolePermissionSeeder::class,
        ]);
    }
}

Membuat Middleware untuk Permission Check

Buat middleware khusus untuk check permissions:

php artisan make:middleware CheckPermission

Edit file app/Http/Middleware/CheckPermission.php:

<?php

namespace App\\Http\\Middleware;

use Closure;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Auth;
use Symfony\\Component\\HttpFoundation\\Response;

class CheckPermission
{
    public function handle(Request $request, Closure $next, string $permission): Response
    {
        if (!Auth::check()) {
            return redirect()->route('login');
        }

        if (!Auth::user()->can($permission)) {
            abort(403, "Access denied. You don't have permission: {$permission}");
        }

        return $next($request);
    }
}

Register middleware di app/Http/Kernel.php:

protected $middlewareAliases = [
    // ... other middleware
    'permission' => \\App\\Http\\Middleware\\CheckPermission::class,
];

Update Filament Resources dengan Permission Control

Edit CategoryResource untuk add permission control di app/Filament/Resources/CategoryResource.php:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\CategoryResource\\Pages;
use App\\Models\\Category;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;

class CategoryResource extends Resource
{
    protected static ?string $model = Category::class;

    protected static ?string $navigationIcon = 'heroicon-o-tag';

    protected static ?string $navigationGroup = 'Product Management';

    protected static ?int $navigationSort = 1;

    public static function canViewAny(): bool
    {
        return auth()->user()->can('view_categories');
    }

    public static function canCreate(): bool
    {
        return auth()->user()->can('create_categories');
    }

    public static function canEdit($record): bool
    {
        return auth()->user()->can('edit_categories');
    }

    public static function canDelete($record): bool
    {
        return auth()->user()->can('delete_categories');
    }

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\TextInput::make('name')
                    ->required()
                    ->maxLength(255)
                    ->live(onBlur: true)
                    ->afterStateUpdated(fn (string $context, $state, callable $set) =>
                        $context === 'create' ? $set('slug', \\Illuminate\\Support\\Str::slug($state)) : null
                    ),

                Forms\\Components\\TextInput::make('slug')
                    ->required()
                    ->maxLength(255)
                    ->unique(Category::class, 'slug', ignoreRecord: true),

                Forms\\Components\\Textarea::make('description')
                    ->columnSpanFull()
                    ->rows(4),

                Forms\\Components\\TextInput::make('icon')
                    ->maxLength(255)
                    ->placeholder('heroicon-o-star'),

                Forms\\Components\\Toggle::make('is_active')
                    ->default(true),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('name')
                    ->searchable()
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('slug')
                    ->searchable()
                    ->copyable()
                    ->copyMessage('Slug copied!')
                    ->color('gray'),

                Tables\\Columns\\TextColumn::make('products_count')
                    ->counts('products')
                    ->label('Products')
                    ->badge()
                    ->color('success'),

                Tables\\Columns\\IconColumn::make('is_active')
                    ->boolean()
                    ->label('Active'),

                Tables\\Columns\\TextColumn::make('created_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                Tables\\Filters\\TernaryFilter::make('is_active')
                    ->label('Active Status')
                    ->boolean()
                    ->trueLabel('Active only')
                    ->falseLabel('Inactive only')
                    ->native(false),
            ])
            ->actions([
                Tables\\Actions\\EditAction::make()
                    ->visible(fn () => auth()->user()->can('edit_categories')),
                Tables\\Actions\\DeleteAction::make()
                    ->visible(fn () => auth()->user()->can('delete_categories')),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make()
                        ->visible(fn () => auth()->user()->can('delete_categories')),
                ]),
            ]);
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\\ListCategories::route('/'),
            'create' => Pages\\CreateCategory::route('/create'),
            'edit' => Pages\\EditCategory::route('/{record}/edit'),
        ];
    }
}

Update ProductResource dengan Permission Control

Edit app/Filament/Resources/ProductResource.php:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\ProductResource\\Pages;
use App\\Models\\Product;
use Filament\\Resources\\Resource;
// ... other imports

class ProductResource extends Resource
{
    protected static ?string $model = Product::class;

    protected static ?string $navigationIcon = 'heroicon-o-ticket';

    protected static ?string $navigationGroup = 'Product Management';

    protected static ?int $navigationSort = 2;

    public static function canViewAny(): bool
    {
        return auth()->user()->can('view_products');
    }

    public static function canCreate(): bool
    {
        return auth()->user()->can('create_products');
    }

    public static function canEdit($record): bool
    {
        return auth()->user()->can('edit_products');
    }

    public static function canDelete($record): bool
    {
        return auth()->user()->can('delete_products');
    }

    // Include previous form and table methods here...
    // Add permission checks to actions similar to CategoryResource
}

Update OrderResource dengan Permission Control

Edit app/Filament/Resources/OrderResource.php:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\OrderResource\\Pages;
use App\\Models\\Order;
use Filament\\Resources\\Resource;
// ... other imports

class OrderResource extends Resource
{
    protected static ?string $model = Order::class;

    protected static ?string $navigationIcon = 'heroicon-o-shopping-cart';

    protected static ?string $navigationGroup = 'Sales Management';

    protected static ?int $navigationSort = 1;

    public static function canViewAny(): bool
    {
        return auth()->user()->can('view_orders');
    }

    public static function canCreate(): bool
    {
        return auth()->user()->can('create_orders');
    }

    public static function canEdit($record): bool
    {
        return auth()->user()->can('edit_orders');
    }

    public static function canDelete($record): bool
    {
        return auth()->user()->can('delete_orders');
    }

    // Apply scoping based on user role
    public static function getEloquentQuery(): Builder
    {
        $query = parent::getEloquentQuery();

        $user = auth()->user();

        // Sales staff can only see orders they created or need to handle
        if ($user->hasRole('sales_staff') && !$user->hasRole('admin')) {
            // Add additional filtering if needed
            // $query->where('created_by', $user->id);
        }

        return $query;
    }

    // Include previous form and table methods with permission checks...
}

Membuat Role Management Resource

Buat resource untuk manage roles dan permissions:

php artisan make:filament-resource Role --generate

Edit app/Filament/Resources/RoleResource.php:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\RoleResource\\Pages;
use Spatie\\Permission\\Models\\Role;
use Spatie\\Permission\\Models\\Permission;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;

class RoleResource extends Resource
{
    protected static ?string $model = Role::class;

    protected static ?string $navigationIcon = 'heroicon-o-shield-check';

    protected static ?string $navigationGroup = 'User Management';

    protected static ?int $navigationSort = 2;

    public static function canViewAny(): bool
    {
        return auth()->user()->can('assign_roles');
    }

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\TextInput::make('name')
                    ->required()
                    ->unique(Role::class, 'name', ignoreRecord: true)
                    ->maxLength(255),

                Forms\\Components\\Select::make('permissions')
                    ->multiple()
                    ->relationship('permissions', 'name')
                    ->options(Permission::all()->pluck('name', 'id'))
                    ->searchable()
                    ->preload()
                    ->columnSpanFull(),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('name')
                    ->searchable()
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('permissions_count')
                    ->counts('permissions')
                    ->label('Permissions')
                    ->badge()
                    ->color('primary'),

                Tables\\Columns\\TextColumn::make('users_count')
                    ->counts('users')
                    ->label('Users')
                    ->badge()
                    ->color('success'),

                Tables\\Columns\\TextColumn::make('created_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                //
            ])
            ->actions([
                Tables\\Actions\\EditAction::make(),
                Tables\\Actions\\DeleteAction::make()
                    ->before(function (Role $record) {
                        // Detach all permissions before deleting
                        $record->permissions()->detach();
                        // Detach all users before deleting
                        $record->users()->detach();
                    }),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make(),
                ]),
            ]);
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\\ListRoles::route('/'),
            'create' => Pages\\CreateRole::route('/create'),
            'edit' => Pages\\EditRole::route('/{record}/edit'),
        ];
    }
}

Update UserResource untuk Role Assignment

Edit app/Filament/Resources/UserResource.php untuk add role management:

public static function form(Form $form): Form
{
    return $form
        ->schema([
            Forms\\Components\\Section::make('User Information')
                ->schema([
                    Forms\\Components\\TextInput::make('name')
                        ->required()
                        ->maxLength(255),

                    Forms\\Components\\TextInput::make('email')
                        ->email()
                        ->required()
                        ->unique(User::class, 'email', ignoreRecord: true)
                        ->maxLength(255),

                    Forms\\Components\\TextInput::make('password')
                        ->password()
                        ->dehydrateStateUsing(fn ($state) => filled($state) ? Hash::make($state) : null)
                        ->dehydrated(fn ($state) => filled($state))
                        ->required(fn (string $context): bool => $context === 'create')
                        ->maxLength(255),
                ])
                ->columns(2),

            Forms\\Components\\Section::make('Roles & Permissions')
                ->schema([
                    Forms\\Components\\Select::make('roles')
                        ->multiple()
                        ->relationship('roles', 'name')
                        ->options(\\Spatie\\Permission\\Models\\Role::all()->pluck('name', 'id'))
                        ->searchable()
                        ->preload(),

                    Forms\\Components\\Select::make('permissions')
                        ->multiple()
                        ->relationship('permissions', 'name')
                        ->options(\\Spatie\\Permission\\Models\\Permission::all()->pluck('name', 'id'))
                        ->searchable()
                        ->preload()
                        ->helperText('Direct permissions (in addition to role permissions)'),

                    Forms\\Components\\Toggle::make('is_admin')
                        ->label('Super Admin Access')
                        ->default(false),
                ])
                ->columns(2),
        ]);
}

public static function table(Table $table): Table
{
    return $table
        ->columns([
            Tables\\Columns\\TextColumn::make('name')
                ->searchable()
                ->sortable(),

            Tables\\Columns\\TextColumn::make('email')
                ->searchable()
                ->copyable(),

            Tables\\Columns\\TextColumn::make('roles.name')
                ->badge()
                ->color(fn (string $state): string => match ($state) {
                    'admin' => 'danger',
                    'product_manager' => 'warning',
                    'sales_staff' => 'info',
                    'customer' => 'primary',
                    default => 'gray',
                }),

            Tables\\Columns\\IconColumn::make('is_admin')
                ->boolean()
                ->label('Super Admin'),

            Tables\\Columns\\IconColumn::make('email_verified_at')
                ->boolean()
                ->label('Verified')
                ->getStateUsing(fn ($record) => !is_null($record->email_verified_at)),

            Tables\\Columns\\TextColumn::make('last_login_at')
                ->dateTime()
                ->sortable()
                ->toggleable(),
        ])
        ->filters([
            Tables\\Filters\\SelectFilter::make('roles')
                ->relationship('roles', 'name')
                ->multiple(),

            Tables\\Filters\\TernaryFilter::make('is_admin')
                ->label('Super Admin Access'),
        ])
        // ... rest of table configuration
}

Testing Permission System

Buat command untuk test permission system:

php artisan make:command TestPermissions

Edit app/Console/Commands/TestPermissions.php:

<?php

namespace App\\Console\\Commands;

use Illuminate\\Console\\Command;
use App\\Models\\User;
use Spatie\\Permission\\Models\\Role;

class TestPermissions extends Command
{
    protected $signature = 'test:permissions {email}';
    protected $description = 'Test user permissions';

    public function handle()
    {
        $email = $this->argument('email');
        $user = User::where('email', $email)->first();

        if (!$user) {
            $this->error("User with email {$email} not found.");
            return;
        }

        $this->info("Testing permissions for: {$user->name} ({$user->email})");
        $this->info("Roles: " . $user->roles->pluck('name')->join(', '));

        $permissions = [
            'view_products', 'edit_products', 'view_orders', 'edit_orders',
            'view_users', 'assign_roles', 'view_reports'
        ];

        foreach ($permissions as $permission) {
            $hasPermission = $user->can($permission) ? '✓' : '✗';
            $this->line("{$hasPermission} {$permission}");
        }
    }
}

Test dengan command:

php artisan test:permissions [email protected]

Membuat Demo Users dengan Different Roles

Buat seeder untuk demo users:

php artisan make:seeder DemoUserSeeder

Edit database/seeders/DemoUserSeeder.php:

<?php

namespace Database\\Seeders;

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

class DemoUserSeeder extends Seeder
{
    public function run(): void
    {
        // Product Manager
        $productManager = User::create([
            'name' => 'John Product Manager',
            'email' => '[email protected]',
            'password' => bcrypt('password'),
            'email_verified_at' => now(),
        ]);
        $productManager->assignRole('product_manager');

        // Sales Staff
        $salesStaff = User::create([
            'name' => 'Jane Sales Staff',
            'email' => '[email protected]',
            'password' => bcrypt('password'),
            'email_verified_at' => now(),
        ]);
        $salesStaff->assignRole('sales_staff');

        // Regular Customer
        $customer = User::create([
            'name' => 'Bob Customer',
            'email' => '[email protected]',
            'password' => bcrypt('password'),
            'email_verified_at' => now(),
        ]);
        $customer->assignRole('customer');

        $this->command->info('Demo users created successfully!');
    }
}

Jalankan seeder:

php artisan db:seed --class=DemoUserSeeder

Update Navigation Visibility

Update AdminPanelProvider untuk hide navigation items based on permissions:

public function panel(Panel $panel): Panel
{
    return $panel
        ->default()
        ->id('admin')
        ->path('admin')
        ->login()
        ->brandName('Event Ticket Admin')
        ->navigationGroups([
            'Product Management',
            'Sales Management',
            'User Management',
            'Reports',
        ])
        ->middleware([
            // ... middleware configuration
        ])
        ->plugins([
            // Add permission-based navigation if needed
        ]);
}

Dengan setup permission system yang comprehensive ini, sekarang admin bisa assign specific roles ke users dan control access dengan granular level. Sales staff cuma bisa handle orders, product manager fokus ke product management, dan admin punya full control. System ini scalable dan bisa easily diperluas sesuai kebutuhan bisnis yang berkembang!

Memahami Payment Gateway dan Midtrans

Sebelum kita mulai implement payment gateway, mari pahami dulu kenapa integration ini crucial untuk website booking event ticket. Payment gateway adalah bridge antara website kita dan bank/financial institution yang memproses transaksi pembayaran. Midtrans adalah salah satu payment gateway terpopuler di Indonesia yang support berbagai metode pembayaran seperti credit card, bank transfer, e-wallet, dan convenience store.

Yang bikin Midtrans menarik untuk project kita adalah comprehensive API mereka, sandbox environment untuk testing, dan support untuk recurring payments yang bisa berguna untuk event series atau membership. Plus, mereka punya dashboard yang user-friendly untuk monitoring transaksi dan comprehensive webhook system untuk real-time payment updates.

Setup Akun Midtrans dan Konfigurasi

Pertama, kita perlu register akun di Midtrans dan get API credentials. Daftar di https://midtrans.com dan create akun merchant. Setelah akun ter-approve, kamu akan dapat access ke merchant dashboard dan bisa generate API keys.

Di sandbox environment, kamu akan mendapat:

  • Server Key: untuk backend authentication
  • Client Key: untuk frontend integration
  • Merchant ID: identifier untuk merchant kamu

Simpan credentials ini karena akan kita gunakan untuk konfigurasi Laravel.

Instalasi Midtrans PHP SDK

Install Midtrans PHP SDK melalui Composer:

composer require midtrans/midtrans-php

Package ini akan provide semua tools yang dibutuhkan untuk berkomunikasi dengan Midtrans API, termasuk Snap (hosted payment page), Core API (direct API calls), dan Iris (disbursement API).

Konfigurasi Environment Variables

Tambahkan Midtrans configuration di file .env:

# Midtrans Configuration
MIDTRANS_SERVER_KEY=SB-Mid-server-your_server_key_here
MIDTRANS_CLIENT_KEY=SB-Mid-client-your_client_key_here
MIDTRANS_MERCHANT_ID=your_merchant_id_here
MIDTRANS_IS_PRODUCTION=false
MIDTRANS_IS_SANITIZED=true
MIDTRANS_IS_3DS=true

Untuk production environment, ganti MIDTRANS_IS_PRODUCTION ke true dan gunakan production keys yang berbeda.

Membuat Konfigurasi File untuk Midtrans

Buat config file khusus untuk Midtrans di config/midtrans.php:

<?php

return [
    'server_key' => env('MIDTRANS_SERVER_KEY'),
    'client_key' => env('MIDTRANS_CLIENT_KEY'),
    'merchant_id' => env('MIDTRANS_MERCHANT_ID'),
    'is_production' => env('MIDTRANS_IS_PRODUCTION', false),
    'is_sanitized' => env('MIDTRANS_IS_SANITIZED', true),
    'is_3ds' => env('MIDTRANS_IS_3DS', true),

    'curl_options' => [
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json',
            'Accept: application/json',
        ],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_TIMEOUT => 30,
    ],

    'notification_url' => env('APP_URL') . '/midtrans/notification',
    'finish_url' => env('APP_URL') . '/payment/finish',
    'unfinish_url' => env('APP_URL') . '/payment/unfinish',
    'error_url' => env('APP_URL') . '/payment/error',
];

Membuat Service Class untuk Midtrans

Buat service class untuk handle semua Midtrans operations. Generate class baru:

php artisan make:class Services/MidtransService

Edit file app/Services/MidtransService.php:

<?php

namespace App\\Services;

use Midtrans\\Config;
use Midtrans\\Snap;
use Midtrans\\Transaction;
use Midtrans\\Notification;
use App\\Models\\Order;
use Illuminate\\Support\\Facades\\Log;

class MidtransService
{
    public function __construct()
    {
        // Set Midtrans configuration
        Config::$serverKey = config('midtrans.server_key');
        Config::$clientKey = config('midtrans.client_key');
        Config::$isProduction = config('midtrans.is_production');
        Config::$isSanitized = config('midtrans.is_sanitized');
        Config::$is3ds = config('midtrans.is_3ds');
    }

    public function createTransaction(Order $order): array
    {
        try {
            $params = $this->buildTransactionParams($order);
            $snapToken = Snap::getSnapToken($params);

            // Update order dengan snap token
            $order->update([
                'payment_details' => [
                    'snap_token' => $snapToken,
                    'created_at' => now(),
                ]
            ]);

            return [
                'success' => true,
                'snap_token' => $snapToken,
                'redirect_url' => "<https://app.sandbox.midtrans.com/snap/v2/vtweb/{$snapToken}>"
            ];
        } catch (\\Exception $e) {
            Log::error('Midtrans transaction creation failed: ' . $e->getMessage());
            return [
                'success' => false,
                'message' => 'Failed to create payment transaction: ' . $e->getMessage()
            ];
        }
    }

    private function buildTransactionParams(Order $order): array
    {
        $customer = $order->customer;
        $product = $order->product;

        return [
            'transaction_details' => [
                'order_id' => $order->order_number,
                'gross_amount' => (int) $order->final_amount,
            ],
            'item_details' => [
                [
                    'id' => $product->id,
                    'price' => (int) $order->unit_price,
                    'quantity' => $order->quantity,
                    'name' => $product->name,
                    'brand' => 'Event Ticket',
                    'category' => $product->category->name ?? 'Event',
                    'merchant_name' => config('app.name'),
                ]
            ],
            'customer_details' => [
                'first_name' => $customer->name,
                'email' => $customer->email,
                'phone' => $customer->phone ?? '',
                'billing_address' => [
                    'first_name' => $customer->name,
                    'email' => $customer->email,
                    'phone' => $customer->phone ?? '',
                    'address' => $customer->address ?? '',
                    'city' => $customer->city ?? '',
                    'postal_code' => $customer->postal_code ?? '',
                    'country_code' => 'IDN'
                ]
            ],
            'enabled_payments' => [
                'credit_card', 'mandiri_clickpay', 'cimb_clicks',
                'bca_klikbca', 'bca_klikpay', 'bri_epay', 'echannel',
                'permata_va', 'bca_va', 'bni_va', 'other_va',
                'gopay', 'shopeepay', 'dana', 'linkaja',
                'indomaret', 'alfamart'
            ],
            'credit_card' => [
                'secure' => true,
                'bank' => 'bca',
                'installment' => [
                    'required' => false,
                    'terms' => [
                        'bni' => [3, 6, 12],
                        'mandiri' => [3, 6, 12],
                        'cimb' => [3, 6, 12],
                        'bca' => [3, 6, 12],
                        'maybank' => [3, 6, 12],
                    ]
                ]
            ],
            'callbacks' => [
                'finish' => config('midtrans.finish_url'),
                'unfinish' => config('midtrans.unfinish_url'),
                'error' => config('midtrans.error_url'),
            ],
            'expiry' => [
                'start_time' => date('Y-m-d H:i:s O'),
                'unit' => 'hours',
                'duration' => 24
            ],
            'custom_field1' => $order->id,
            'custom_field2' => $customer->id,
            'custom_field3' => $product->id,
        ];
    }

    public function handleNotification(): array
    {
        try {
            $notification = new Notification();

            $order = Order::where('order_number', $notification->order_id)->first();

            if (!$order) {
                Log::error('Order not found for notification: ' . $notification->order_id);
                return ['success' => false, 'message' => 'Order not found'];
            }

            $transactionStatus = $notification->transaction_status;
            $fraudStatus = $notification->fraud_status ?? null;
            $paymentType = $notification->payment_type;

            Log::info('Midtrans notification received', [
                'order_id' => $notification->order_id,
                'transaction_status' => $transactionStatus,
                'fraud_status' => $fraudStatus,
                'payment_type' => $paymentType
            ]);

            $this->updateOrderStatus($order, $transactionStatus, $fraudStatus, $notification);

            return ['success' => true, 'message' => 'Notification processed successfully'];
        } catch (\\Exception $e) {
            Log::error('Midtrans notification handling failed: ' . $e->getMessage());
            return ['success' => false, 'message' => $e->getMessage()];
        }
    }

    private function updateOrderStatus(Order $order, string $transactionStatus, ?string $fraudStatus, $notification): void
    {
        $paymentDetails = $order->payment_details ?? [];
        $paymentDetails['notification_data'] = [
            'transaction_status' => $transactionStatus,
            'fraud_status' => $fraudStatus,
            'payment_type' => $notification->payment_type,
            'transaction_id' => $notification->transaction_id,
            'transaction_time' => $notification->transaction_time,
            'settlement_time' => $notification->settlement_time ?? null,
            'received_at' => now(),
        ];

        switch ($transactionStatus) {
            case 'capture':
                if ($fraudStatus == 'challenge') {
                    $order->update([
                        'status' => 'pending',
                        'payment_status' => 'partial',
                        'payment_details' => $paymentDetails,
                        'notes' => 'Payment challenged by fraud detection'
                    ]);
                } elseif ($fraudStatus == 'accept') {
                    $order->update([
                        'status' => 'paid',
                        'payment_status' => 'paid',
                        'payment_date' => now(),
                        'payment_method' => $notification->payment_type,
                        'payment_reference' => $notification->transaction_id,
                        'payment_details' => $paymentDetails,
                    ]);
                }
                break;

            case 'settlement':
                $order->update([
                    'status' => 'paid',
                    'payment_status' => 'paid',
                    'payment_date' => now(),
                    'payment_method' => $notification->payment_type,
                    'payment_reference' => $notification->transaction_id,
                    'payment_details' => $paymentDetails,
                ]);
                break;

            case 'pending':
                $order->update([
                    'status' => 'pending',
                    'payment_status' => 'unpaid',
                    'payment_method' => $notification->payment_type,
                    'payment_reference' => $notification->transaction_id,
                    'payment_details' => $paymentDetails,
                ]);
                break;

            case 'deny':
            case 'cancel':
            case 'expire':
                $order->update([
                    'status' => 'cancelled',
                    'payment_status' => 'unpaid',
                    'payment_details' => $paymentDetails,
                    'notes' => "Payment {$transactionStatus}"
                ]);
                break;

            case 'refund':
            case 'partial_refund':
                $order->update([
                    'status' => 'refunded',
                    'payment_status' => 'refunded',
                    'payment_details' => $paymentDetails,
                ]);
                break;
        }
    }

    public function getTransactionStatus(string $orderId): array
    {
        try {
            $status = Transaction::status($orderId);
            return [
                'success' => true,
                'data' => $status
            ];
        } catch (\\Exception $e) {
            Log::error('Failed to get transaction status: ' . $e->getMessage());
            return [
                'success' => false,
                'message' => $e->getMessage()
            ];
        }
    }

    public function cancelTransaction(string $orderId): array
    {
        try {
            $result = Transaction::cancel($orderId);
            return [
                'success' => true,
                'data' => $result
            ];
        } catch (\\Exception $e) {
            Log::error('Failed to cancel transaction: ' . $e->getMessage());
            return [
                'success' => false,
                'message' => $e->getMessage()
            ];
        }
    }

    public function refundTransaction(string $orderId, int $amount = null, string $reason = ''): array
    {
        try {
            $params = [
                'refund_key' => uniqid(),
                'reason' => $reason ?: 'Customer refund request'
            ];

            if ($amount) {
                $params['amount'] = $amount;
            }

            $result = Transaction::refund($orderId, $params);
            return [
                'success' => true,
                'data' => $result
            ];
        } catch (\\Exception $e) {
            Log::error('Failed to refund transaction: ' . $e->getMessage());
            return [
                'success' => false,
                'message' => $e->getMessage()
            ];
        }
    }
}

Membuat Controller untuk Payment Operations

Buat controller untuk handle payment operations:

php artisan make:controller PaymentController

Edit file app/Http/Controllers/PaymentController.php:

<?php

namespace App\\Http\\Controllers;

use App\\Models\\Order;
use App\\Services\\MidtransService;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Log;

class PaymentController extends Controller
{
    protected $midtransService;

    public function __construct(MidtransService $midtransService)
    {
        $this->midtransService = $midtransService;
    }

    public function createPayment(Request $request, Order $order)
    {
        try {
            // Validate order can be paid
            if ($order->status !== 'pending') {
                return response()->json([
                    'success' => false,
                    'message' => 'Order cannot be paid. Current status: ' . $order->status
                ], 400);
            }

            $result = $this->midtransService->createTransaction($order);

            if ($result['success']) {
                return response()->json([
                    'success' => true,
                    'snap_token' => $result['snap_token'],
                    'redirect_url' => $result['redirect_url']
                ]);
            }

            return response()->json([
                'success' => false,
                'message' => $result['message']
            ], 500);

        } catch (\\Exception $e) {
            Log::error('Payment creation failed: ' . $e->getMessage());
            return response()->json([
                'success' => false,
                'message' => 'Failed to create payment'
            ], 500);
        }
    }

    public function notification(Request $request)
    {
        try {
            Log::info('Midtrans notification received', $request->all());

            $result = $this->midtransService->handleNotification();

            if ($result['success']) {
                return response()->json(['success' => true]);
            }

            return response()->json(['success' => false], 400);
        } catch (\\Exception $e) {
            Log::error('Notification handling failed: ' . $e->getMessage());
            return response()->json(['success' => false], 500);
        }
    }

    public function finish(Request $request)
    {
        $orderId = $request->get('order_id');
        $order = Order::where('order_number', $orderId)->first();

        if (!$order) {
            return redirect()->route('home')->with('error', 'Order not found');
        }

        // Get latest transaction status
        $statusResult = $this->midtransService->getTransactionStatus($orderId);

        if ($statusResult['success']) {
            $transactionStatus = $statusResult['data']->transaction_status;

            switch ($transactionStatus) {
                case 'settlement':
                case 'capture':
                    return view('payment.success', compact('order'));
                case 'pending':
                    return view('payment.pending', compact('order'));
                default:
                    return view('payment.failed', compact('order'));
            }
        }

        return view('payment.success', compact('order'));
    }

    public function unfinish(Request $request)
    {
        $orderId = $request->get('order_id');
        $order = Order::where('order_number', $orderId)->first();

        return view('payment.pending', compact('order'));
    }

    public function error(Request $request)
    {
        $orderId = $request->get('order_id');
        $order = Order::where('order_number', $orderId)->first();

        return view('payment.failed', compact('order'));
    }

    public function checkStatus(Order $order)
    {
        $result = $this->midtransService->getTransactionStatus($order->order_number);

        if ($result['success']) {
            return response()->json([
                'success' => true,
                'status' => $result['data']->transaction_status,
                'data' => $result['data']
            ]);
        }

        return response()->json([
            'success' => false,
            'message' => $result['message']
        ], 500);
    }
}

Menambahkan Payment Actions ke OrderResource

Update OrderResource untuk add payment management capabilities. Edit app/Filament/Resources/OrderResource.php dan tambahkan actions:

use Filament\\Tables\\Actions\\Action;
use App\\Services\\MidtransService;

public static function table(Table $table): Table
{
    return $table
        ->columns([
            // ... existing columns
        ])
        ->actions([
            Tables\\Actions\\ViewAction::make(),
            Tables\\Actions\\EditAction::make(),

            Action::make('create_payment')
                ->label('Create Payment')
                ->icon('heroicon-o-credit-card')
                ->color('success')
                ->visible(fn (Order $record): bool =>
                    $record->status === 'pending' &&
                    $record->payment_status === 'unpaid' &&
                    auth()->user()->can('process_payments')
                )
                ->action(function (Order $record) {
                    $midtransService = app(MidtransService::class);
                    $result = $midtransService->createTransaction($record);

                    if ($result['success']) {
                        Notification::make()
                            ->title('Payment link created successfully')
                            ->success()
                            ->send();

                        // Open payment page in new tab
                        redirect()->away($result['redirect_url']);
                    } else {
                        Notification::make()
                            ->title('Failed to create payment')
                            ->body($result['message'])
                            ->danger()
                            ->send();
                    }
                }),

            Action::make('check_status')
                ->label('Check Status')
                ->icon('heroicon-o-arrow-path')
                ->color('info')
                ->visible(fn (Order $record): bool =>
                    !empty($record->payment_reference) &&
                    auth()->user()->can('view_orders')
                )
                ->action(function (Order $record) {
                    $midtransService = app(MidtransService::class);
                    $result = $midtransService->getTransactionStatus($record->order_number);

                    if ($result['success']) {
                        $status = $result['data']->transaction_status;
                        Notification::make()
                            ->title('Payment Status: ' . ucfirst($status))
                            ->body('Last updated: ' . now()->format('Y-m-d H:i:s'))
                            ->info()
                            ->send();
                    } else {
                        Notification::make()
                            ->title('Failed to check payment status')
                            ->danger()
                            ->send();
                    }
                }),

            Action::make('refund')
                ->label('Refund')
                ->icon('heroicon-o-arrow-uturn-left')
                ->color('danger')
                ->visible(fn (Order $record): bool =>
                    $record->payment_status === 'paid' &&
                    auth()->user()->can('refund_orders')
                )
                ->requiresConfirmation()
                ->modalDescription('Are you sure you want to refund this payment? This action cannot be undone.')
                ->form([
                    Forms\\Components\\TextInput::make('refund_amount')
                        ->label('Refund Amount')
                        ->numeric()
                        ->prefix('Rp')
                        ->helperText('Leave empty for full refund'),
                    Forms\\Components\\Textarea::make('refund_reason')
                        ->label('Refund Reason')
                        ->required()
                        ->rows(3),
                ])
                ->action(function (Order $record, array $data) {
                    $midtransService = app(MidtransService::class);
                    $amount = $data['refund_amount'] ? (int) $data['refund_amount'] : null;

                    $result = $midtransService->refundTransaction(
                        $record->order_number,
                        $amount,
                        $data['refund_reason']
                    );

                    if ($result['success']) {
                        Notification::make()
                            ->title('Refund processed successfully')
                            ->success()
                            ->send();
                    } else {
                        Notification::make()
                            ->title('Failed to process refund')
                            ->body($result['message'])
                            ->danger()
                            ->send();
                    }
                }),

            Tables\\Actions\\DeleteAction::make()
                ->visible(fn () => auth()->user()->can('delete_orders')),
        ])
        // ... rest of table configuration
}

Membuat Routes untuk Payment

Tambahkan routes untuk payment operations di routes/web.php:

use App\\Http\\Controllers\\PaymentController;

Route::prefix('payment')->group(function () {
    Route::post('/create/{order}', [PaymentController::class, 'createPayment'])
        ->name('payment.create')
        ->middleware('auth');

    Route::get('/check-status/{order}', [PaymentController::class, 'checkStatus'])
        ->name('payment.check-status')
        ->middleware('auth');
});

// Public routes for Midtrans callbacks
Route::prefix('midtrans')->group(function () {
    Route::post('/notification', [PaymentController::class, 'notification'])
        ->name('midtrans.notification');

    Route::get('/finish', [PaymentController::class, 'finish'])
        ->name('midtrans.finish');

    Route::get('/unfinish', [PaymentController::class, 'unfinish'])
        ->name('midtrans.unfinish');

    Route::get('/error', [PaymentController::class, 'error'])
        ->name('midtrans.error');
});

Membuat Views untuk Payment Pages

Buat view untuk payment success di resources/views/payment/success.blade.php:

<x-app-layout>
    <div class="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
        <div class="sm:mx-auto sm:w-full sm:max-w-md">
            <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
                <div class="text-center">
                    <div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
                        <svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
                        </svg>
                    </div>

                    <h2 class="mt-6 text-3xl font-extrabold text-gray-900">Payment Successful!</h2>

                    <p class="mt-2 text-sm text-gray-600">
                        Your payment has been processed successfully.
                    </p>

                    <div class="mt-6 bg-gray-50 px-4 py-5 sm:p-6 rounded-md">
                        <dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
                            <div>
                                <dt class="text-sm font-medium text-gray-500">Order Number</dt>
                                <dd class="mt-1 text-sm text-gray-900">{{ $order->order_number }}</dd>
                            </div>
                            <div>
                                <dt class="text-sm font-medium text-gray-500">Event</dt>
                                <dd class="mt-1 text-sm text-gray-900">{{ $order->product->name }}</dd>
                            </div>
                            <div>
                                <dt class="text-sm font-medium text-gray-500">Total Amount</dt>
                                <dd class="mt-1 text-sm text-gray-900">Rp {{ number_format($order->final_amount, 0, ',', '.') }}</dd>
                            </div>
                            <div>
                                <dt class="text-sm font-medium text-gray-500">Payment Date</dt>
                                <dd class="mt-1 text-sm text-gray-900">{{ $order->payment_date?->format('d M Y H:i') }}</dd>
                            </div>
                        </dl>
                    </div>

                    <div class="mt-6">
                        <a href="{{ route('home') }}"
                           class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
                            Back to Home
                        </a>
                    </div>
                </div>
            </div>
        </div>
    </div>
</x-app-layout>

Buat view untuk payment pending di resources/views/payment/pending.blade.php:

<x-app-layout>
    <div class="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
        <div class="sm:mx-auto sm:w-full sm:max-w-md">
            <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
                <div class="text-center">
                    <div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-yellow-100">
                        <svg class="h-6 w-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
                        </svg>
                    </div>

                    <h2 class="mt-6 text-3xl font-extrabold text-gray-900">Payment Pending</h2>

                    <p class="mt-2 text-sm text-gray-600">
                        Your payment is being processed. Please wait for confirmation.
                    </p>

                    <div class="mt-6 bg-gray-50 px-4 py-5 sm:p-6 rounded-md">
                        <dl class="grid grid-cols-1 gap-x-4 gap-y-4">
                            <div>
                                <dt class="text-sm font-medium text-gray-500">Order Number</dt>
                                <dd class="mt-1 text-sm text-gray-900">{{ $order->order_number }}</dd>
                            </div>
                            <div>
                                <dt class="text-sm font-medium text-gray-500">Total Amount</dt>
                                <dd class="mt-1 text-sm text-gray-900">Rp {{ number_format($order->final_amount, 0, ',', '.') }}</dd>
                            </div>
                        </dl>
                    </div>

                    <div class="mt-6 space-y-3">
                        <button onclick="checkPaymentStatus()"
                                class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700">
                            Check Payment Status
                        </button>

                        <a href="{{ route('home') }}"
                           class="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
                            Back to Home
                        </a>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script>
        function checkPaymentStatus() {
            fetch('{{ route("payment.check-status", $order) }}')
                .then(response => response.json())
                .then(data => {
                    if (data.success) {
                        if (data.status === 'settlement' || data.status === 'capture') {
                            window.location.reload();
                        } else {
                            alert('Payment status: ' + data.status);
                        }
                    }
                })
                .catch(error => console.error('Error:', error));
        }

        // Auto check status every 30 seconds
        setInterval(checkPaymentStatus, 30000);
    </script>
</x-app-layout>

Membuat Command untuk Sync Payment Status

Buat artisan command untuk sync payment status secara batch:

php artisan make:command SyncPaymentStatus

Edit app/Console/Commands/SyncPaymentStatus.php:

<?php

namespace App\\Console\\Commands;

use Illuminate\\Console\\Command;
use App\\Models\\Order;
use App\\Services\\MidtransService;

class SyncPaymentStatus extends Command
{
    protected $signature = 'payment:sync {--days=7}';
    protected $description = 'Sync payment status for pending orders';

    public function handle()
    {
        $days = $this->option('days');
        $midtransService = app(MidtransService::class);

        $pendingOrders = Order::where('payment_status', 'unpaid')
            ->whereNotNull('payment_reference')
            ->where('created_at', '>=', now()->subDays($days))
            ->get();

        $this->info("Found {$pendingOrders->count()} pending orders to sync");

        $updated = 0;
        foreach ($pendingOrders as $order) {
            $result = $midtransService->getTransactionStatus($order->order_number);

            if ($result['success']) {
                $this->line("Checking order: {$order->order_number}");
                $updated++;
            }
        }

        $this->info("Sync completed. {$updated} orders processed.");
    }
}

Testing Payment Integration

Untuk test payment integration, kamu bisa menggunakan test credentials dari Midtrans sandbox. Mereka provide berbagai test card numbers untuk simulate different scenarios:

Test Credit Cards:

  • Success: 4811 1111 1111 1114
  • Failure: 4911 1111 1111 1113
  • Challenge by FDS: 4411 1111 1111 1118

Test Bank Transfer:

  • BCA Virtual Account akan generate nomor VA yang bisa dibayar melalui simulator
  • Permata Virtual Account juga punya test scenario serupa

Setup notification URL di Midtrans dashboard ke https://yourapp.com/midtrans/notification dan pastikan webhook dapat menerima POST requests dari Midtrans servers.

Monitoring dan Logging

Untuk production environment, pastikan implement proper monitoring dan logging:

Set up log rotation untuk payment logs karena volume bisa besar dengan traffic tinggi.

Consider menggunakan external monitoring service seperti Sentry atau Bugsnag untuk track payment errors real-time.

Setup alerts untuk failed payments atau unusual transaction patterns yang bisa indicate fraud atau technical issues.

Regular reconciliation antara database orders dan Midtrans transaction reports untuk ensure data consistency.

Dengan integration yang comprehensive ini, payment system kamu sekarang punya capabilities untuk handle berbagai payment methods, real-time status updates, dan comprehensive admin management tools. System ini scalable dan ready untuk production dengan proper error handling dan monitoring!

Memahami Email Notifeication System

Email notification adalah salah satu fitur yang paling crucial dalam website booking event ticket karena memberikan transparency dan trust kepada customer tentang status order mereka. System notification yang baik tidak hanya informative tapi juga engaging dan professional. Customer perlu tahu kapan order dibuat, kapan payment berhasil, dan juga reminder kalau ada pending payment.

Laravel menyediakan powerful email system yang built on top of SwiftMailer dengan support untuk berbagai email drivers seperti SMTP, Mailgun, SES, dan lainnya. Dengan Laravel's Mailable classes, kita bisa create reusable dan testable email templates yang maintain consistency across all communications.

Konfigurasi Email Driver

Pertama, kita perlu setup email configuration di file .env. Untuk development, kita bisa pakai Mailtrap atau Gmail SMTP:

# Email Configuration
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=your_mailtrap_username
MAIL_PASSWORD=your_mailtrap_password
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="${APP_NAME}"

# Alternative Gmail Configuration
# MAIL_MAILER=smtp
# MAIL_HOST=smtp.gmail.com
# MAIL_PORT=587
# [email protected]
# MAIL_PASSWORD=your_app_password
# MAIL_ENCRYPTION=tls

Untuk production, consider menggunakan service seperti Mailgun, SendGrid, atau Amazon SES yang lebih reliable dan scalable untuk high volume emails.

Membuat Event Classes untuk Order Status Changes

Sebelum membuat Mailable classes, kita perlu create event system yang akan trigger email notifications. Generate event classes:

php artisan make:event OrderCreated
php artisan make:event OrderStatusChanged
php artisan make:event PaymentReceived
php artisan make:event PaymentReminder

Edit app/Events/OrderCreated.php:

<?php

namespace App\\Events;

use App\\Models\\Order;
use Illuminate\\Broadcasting\\InteractsWithSockets;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;

class OrderCreated
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public Order $order;

    public function __construct(Order $order)
    {
        $this->order = $order;
    }
}

Edit app/Events/OrderStatusChanged.php:

<?php

namespace App\\Events;

use App\\Models\\Order;
use Illuminate\\Broadcasting\\InteractsWithSockets;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;

class OrderStatusChanged
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public Order $order;
    public string $previousStatus;
    public string $newStatus;

    public function __construct(Order $order, string $previousStatus, string $newStatus)
    {
        $this->order = $order;
        $this->previousStatus = $previousStatus;
        $this->newStatus = $newStatus;
    }
}

Edit app/Events/PaymentReceived.php:

<?php

namespace App\\Events;

use App\\Models\\Order;
use Illuminate\\Broadcasting\\InteractsWithSockets;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;

class PaymentReceived
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public Order $order;

    public function __construct(Order $order)
    {
        $this->order = $order;
    }
}

Edit app/Events/PaymentReminder.php:

<?php

namespace App\\Events;

use App\\Models\\Order;
use Illuminate\\Broadcasting\\InteractsWithSockets;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;

class PaymentReminder
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public Order $order;

    public function __construct(Order $order)
    {
        $this->order = $order;
    }
}

Membuat Mailable Classes

Sekarang kita create Mailable classes untuk setiap jenis email notification:

php artisan make:mail OrderConfirmation
php artisan make:mail OrderStatusUpdate
php artisan make:mail PaymentConfirmation
php artisan make:mail PaymentReminderMail

Edit app/Mail/OrderConfirmation.php:

<?php

namespace App\\Mail;

use App\\Models\\Order;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Mail\\Mailable;
use Illuminate\\Mail\\Mailables\\Content;
use Illuminate\\Mail\\Mailables\\Envelope;
use Illuminate\\Queue\\SerializesModels;

class OrderConfirmation extends Mailable
{
    use Queueable, SerializesModels;

    public Order $order;

    public function __construct(Order $order)
    {
        $this->order = $order;
    }

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Order Confirmation - ' . $this->order->order_number,
            from: config('mail.from.address'),
        );
    }

    public function content(): Content
    {
        return new Content(
            view: 'emails.order-confirmation',
            with: [
                'order' => $this->order,
                'customer' => $this->order->customer,
                'product' => $this->order->product,
            ]
        );
    }

    public function attachments(): array
    {
        return [];
    }
}

Edit app/Mail/OrderStatusUpdate.php:

<?php

namespace App\\Mail;

use App\\Models\\Order;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Mail\\Mailable;
use Illuminate\\Mail\\Mailables\\Content;
use Illuminate\\Mail\\Mailables\\Envelope;
use Illuminate\\Queue\\SerializesModels;

class OrderStatusUpdate extends Mailable
{
    use Queueable, SerializesModels;

    public Order $order;
    public string $previousStatus;
    public string $newStatus;

    public function __construct(Order $order, string $previousStatus, string $newStatus)
    {
        $this->order = $order;
        $this->previousStatus = $previousStatus;
        $this->newStatus = $newStatus;
    }

    public function envelope(): Envelope
    {
        $statusTitle = ucfirst(str_replace('_', ' ', $this->newStatus));

        return new Envelope(
            subject: "Order {$statusTitle} - {$this->order->order_number}",
            from: config('mail.from.address'),
        );
    }

    public function content(): Content
    {
        return new Content(
            view: 'emails.order-status-update',
            with: [
                'order' => $this->order,
                'customer' => $this->order->customer,
                'product' => $this->order->product,
                'previousStatus' => $this->previousStatus,
                'newStatus' => $this->newStatus,
                'statusMessage' => $this->getStatusMessage(),
            ]
        );
    }

    private function getStatusMessage(): string
    {
        return match ($this->newStatus) {
            'paid' => 'Great news! Your payment has been confirmed and your order is now complete.',
            'cancelled' => 'Your order has been cancelled. If you have any questions, please contact our support team.',
            'refunded' => 'Your refund has been processed and will be credited to your account within 3-5 business days.',
            'pending' => 'Your order is pending payment. Please complete your payment to secure your tickets.',
            default => 'Your order status has been updated.',
        };
    }

    public function attachments(): array
    {
        return [];
    }
}

Edit app/Mail/PaymentConfirmation.php:

<?php

namespace App\\Mail;

use App\\Models\\Order;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Mail\\Mailable;
use Illuminate\\Mail\\Mailables\\Content;
use Illuminate\\Mail\\Mailables\\Envelope;
use Illuminate\\Mail\\Mailables\\Attachment;
use Illuminate\\Queue\\SerializesModels;

class PaymentConfirmation extends Mailable
{
    use Queueable, SerializesModels;

    public Order $order;

    public function __construct(Order $order)
    {
        $this->order = $order;
    }

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Payment Confirmed - Your Tickets are Ready! 🎫',
            from: config('mail.from.address'),
        );
    }

    public function content(): Content
    {
        return new Content(
            view: 'emails.payment-confirmation',
            with: [
                'order' => $this->order,
                'customer' => $this->order->customer,
                'product' => $this->order->product,
                'eventDate' => $this->order->product->event_date,
                'eventLocation' => $this->order->product->event_location,
                'paymentMethod' => $this->getPaymentMethodName(),
            ]
        );
    }

    private function getPaymentMethodName(): string
    {
        return match ($this->order->payment_method) {
            'credit_card' => 'Credit Card',
            'bank_transfer' => 'Bank Transfer',
            'gopay' => 'GoPay',
            'dana' => 'DANA',
            'shopeepay' => 'ShopeePay',
            'bca_va' => 'BCA Virtual Account',
            'bni_va' => 'BNI Virtual Account',
            'bri_va' => 'BRI Virtual Account',
            'mandiri_va' => 'Mandiri Virtual Account',
            'indomaret' => 'Indomaret',
            'alfamart' => 'Alfamart',
            default => ucfirst(str_replace('_', ' ', $this->order->payment_method ?? 'Unknown')),
        };
    }

    public function attachments(): array
    {
        // Optionally attach PDF ticket or receipt
        return [
            // Attachment::fromPath(storage_path('app/tickets/' . $this->order->order_number . '.pdf'))
            //     ->as('ticket-' . $this->order->order_number . '.pdf')
            //     ->withMime('application/pdf'),
        ];
    }
}

Edit app/Mail/PaymentReminderMail.php:

<?php

namespace App\\Mail;

use App\\Models\\Order;
use Carbon\\Carbon;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Mail\\Mailable;
use Illuminate\\Mail\\Mailables\\Content;
use Illuminate\\Mail\\Mailables\\Envelope;
use Illuminate\\Queue\\SerializesModels;

class PaymentReminderMail extends Mailable
{
    use Queueable, SerializesModels;

    public Order $order;

    public function __construct(Order $order)
    {
        $this->order = $order;
    }

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Payment Reminder - Complete Your Order 🕐',
            from: config('mail.from.address'),
        );
    }

    public function content(): Content
    {
        $timeLeft = $this->getTimeLeft();

        return new Content(
            view: 'emails.payment-reminder',
            with: [
                'order' => $this->order,
                'customer' => $this->order->customer,
                'product' => $this->order->product,
                'timeLeft' => $timeLeft,
                'isUrgent' => $timeLeft['hours'] < 6,
                'paymentUrl' => $this->generatePaymentUrl(),
            ]
        );
    }

    private function getTimeLeft(): array
    {
        $createdAt = $this->order->created_at;
        $expiryTime = $createdAt->addHours(24); // 24 hours to complete payment
        $now = Carbon::now();

        if ($now->gt($expiryTime)) {
            return ['expired' => true];
        }

        $diff = $now->diffInHours($expiryTime);
        $minutes = $now->diffInMinutes($expiryTime) % 60;

        return [
            'expired' => false,
            'hours' => $diff,
            'minutes' => $minutes,
            'total_minutes' => $now->diffInMinutes($expiryTime),
        ];
    }

    private function generatePaymentUrl(): string
    {
        return route('orders.show', $this->order) . '?action=pay';
    }

    public function attachments(): array
    {
        return [];
    }
}

Membuat Email Templates

Sekarang kita create email templates yang responsive dan professional. Buat directory resources/views/emails/ dan create layout template dulu.

Buat resources/views/emails/layout.blade.php:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ $subject ?? 'Event Ticket Notification' }}</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            line-height: 1.6;
            color: #333;
            background-color: #f8f9fa;
        }

        .container {
            max-width: 600px;
            margin: 0 auto;
            background-color: #ffffff;
            box-shadow: 0 0 20px rgba(0,0,0,0.1);
        }

        .header {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 30px 20px;
            text-align: center;
        }

        .header h1 {
            font-size: 28px;
            margin-bottom: 10px;
        }

        .header p {
            font-size: 16px;
            opacity: 0.9;
        }

        .content {
            padding: 40px 30px;
        }

        .order-info {
            background-color: #f8f9fa;
            border-radius: 8px;
            padding: 25px;
            margin: 25px 0;
            border-left: 4px solid #667eea;
        }

        .order-info h3 {
            color: #667eea;
            margin-bottom: 15px;
            font-size: 18px;
        }

        .info-row {
            display: flex;
            justify-content: space-between;
            margin-bottom: 10px;
            padding: 8px 0;
            border-bottom: 1px solid #e9ecef;
        }

        .info-row:last-child {
            border-bottom: none;
            margin-bottom: 0;
        }

        .info-label {
            font-weight: 600;
            color: #6c757d;
        }

        .info-value {
            color: #333;
            text-align: right;
        }

        .btn {
            display: inline-block;
            padding: 12px 30px;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            text-decoration: none;
            border-radius: 25px;
            font-weight: 600;
            margin: 20px 0;
            transition: transform 0.2s;
        }

        .btn:hover {
            transform: translateY(-2px);
        }

        .btn-secondary {
            background: #6c757d;
        }

        .btn-success {
            background: #28a745;
        }

        .btn-warning {
            background: #ffc107;
            color: #333;
        }

        .btn-danger {
            background: #dc3545;
        }

        .status-badge {
            display: inline-block;
            padding: 6px 12px;
            border-radius: 15px;
            font-size: 12px;
            font-weight: 600;
            text-transform: uppercase;
        }

        .status-pending {
            background-color: #fff3cd;
            color: #856404;
        }

        .status-paid {
            background-color: #d4edda;
            color: #155724;
        }

        .status-cancelled {
            background-color: #f8d7da;
            color: #721c24;
        }

        .status-refunded {
            background-color: #d1ecf1;
            color: #0c5460;
        }

        .footer {
            background-color: #f8f9fa;
            padding: 30px;
            text-align: center;
            border-top: 1px solid #e9ecef;
        }

        .footer p {
            color: #6c757d;
            font-size: 14px;
            margin-bottom: 10px;
        }

        .social-links {
            margin-top: 20px;
        }

        .social-links a {
            display: inline-block;
            margin: 0 10px;
            color: #667eea;
            text-decoration: none;
        }

        @media only screen and (max-width: 600px) {
            .container {
                width: 100% !important;
            }

            .content {
                padding: 20px 15px;
            }

            .order-info {
                padding: 15px;
            }

            .info-row {
                flex-direction: column;
                align-items: flex-start;
            }

            .info-value {
                text-align: left;
                margin-top: 5px;
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1>{{ config('app.name') }}</h1>
            <p>Your trusted event ticket partner</p>
        </div>

        <div class="content">
            @yield('content')
        </div>

        <div class="footer">
            <p>Thank you for choosing {{ config('app.name') }}!</p>
            <p>If you have any questions, please contact our support team at [email protected]</p>
            <p>&copy; {{ date('Y') }} {{ config('app.name') }}. All rights reserved.</p>

            <div class="social-links">
                <a href="#">Facebook</a>
                <a href="#">Twitter</a>
                <a href="#">Instagram</a>
            </div>
        </div>
    </div>
</body>
</html>

Buat template untuk order confirmation di resources/views/emails/order-confirmation.blade.php:

@extends('emails.layout')

@section('content')
<h2>Order Confirmation</h2>

<p>Hi {{ $customer->name }},</p>

<p>Thank you for your order! We've received your booking and here are the details:</p>

<div class="order-info">
    <h3>Order Details</h3>
    <div class="info-row">
        <span class="info-label">Order Number:</span>
        <span class="info-value"><strong>{{ $order->order_number }}</strong></span>
    </div>
    <div class="info-row">
        <span class="info-label">Event:</span>
        <span class="info-value">{{ $product->name }}</span>
    </div>
    <div class="info-row">
        <span class="info-label">Date & Time:</span>
        <span class="info-value">{{ $product->event_date->format('l, d M Y') }} at {{ $product->event_time->format('H:i') }}</span>
    </div>
    <div class="info-row">
        <span class="info-label">Location:</span>
        <span class="info-value">{{ $product->event_location }}</span>
    </div>
    <div class="info-row">
        <span class="info-label">Quantity:</span>
        <span class="info-value">{{ $order->quantity }} ticket(s)</span>
    </div>
    <div class="info-row">
        <span class="info-label">Total Amount:</span>
        <span class="info-value"><strong>Rp {{ number_format($order->final_amount, 0, ',', '.') }}</strong></span>
    </div>
    <div class="info-row">
        <span class="info-label">Status:</span>
        <span class="info-value">
            <span class="status-badge status-{{ $order->status }}">{{ ucfirst($order->status) }}</span>
        </span>
    </div>
</div>

@if($order->status === 'pending')
<p>To complete your order, please proceed with the payment using the link below:</p>

<div style="text-align: center; margin: 30px 0;">
    <a href="{{ route('orders.show', $order) }}" class="btn">Complete Payment</a>
</div>

<p><strong>Important:</strong> Please complete your payment within 24 hours to secure your tickets.</p>
@endif

<p>You will receive another email once your payment is confirmed. If you have any questions, please don't hesitate to contact us.</p>

<p>We're excited to see you at the event!</p>

<p>Best regards,<br>
{{ config('app.name') }} Team</p>
@endsection

Buat template untuk payment confirmation di resources/views/emails/payment-confirmation.blade.php:

@extends('emails.layout')

@section('content')
<h2>🎉 Payment Confirmed - Your Tickets are Ready!</h2>

<p>Hi {{ $customer->name }},</p>

<p>Fantastic news! Your payment has been successfully processed and your tickets are now confirmed.</p>

<div class="order-info">
    <h3>Ticket Information</h3>
    <div class="info-row">
        <span class="info-label">Order Number:</span>
        <span class="info-value"><strong>{{ $order->order_number }}</strong></span>
    </div>
    <div class="info-row">
        <span class="info-label">Event:</span>
        <span class="info-value">{{ $product->name }}</span>
    </div>
    <div class="info-row">
        <span class="info-label">Date & Time:</span>
        <span class="info-value">{{ $eventDate->format('l, d M Y') }} at {{ $product->event_time->format('H:i') }}</span>
    </div>
    <div class="info-row">
        <span class="info-label">Location:</span>
        <span class="info-value">{{ $eventLocation }}</span>
    </div>
    <div class="info-row">
        <span class="info-label">Tickets:</span>
        <span class="info-value">{{ $order->quantity }} ticket(s)</span>
    </div>
    <div class="info-row">
        <span class="info-label">Payment Method:</span>
        <span class="info-value">{{ $paymentMethod }}</span>
    </div>
    <div class="info-row">
        <span class="info-label">Amount Paid:</span>
        <span class="info-value"><strong>Rp {{ number_format($order->final_amount, 0, ',', '.') }}</strong></span>
    </div>
    <div class="info-row">
        <span class="info-label">Payment Date:</span>
        <span class="info-value">{{ $order->payment_date->format('d M Y H:i') }}</span>
    </div>
</div>

<div style="text-align: center; margin: 30px 0;">
    <a href="{{ route('orders.show', $order) }}" class="btn btn-success">View Tickets</a>
</div>

<div style="background-color: #e7f3ff; padding: 20px; border-radius: 8px; margin: 25px 0;">
    <h4 style="color: #0066cc; margin-bottom: 15px;">📱 What's Next?</h4>
    <ul style="margin-left: 20px; color: #333;">
        <li>Save this email or screenshot your ticket details</li>
        <li>Arrive at the venue 30 minutes before the event starts</li>
        <li>Bring a valid ID for verification</li>
        <li>Present your order number at the entrance</li>
    </ul>
</div>

<p>We can't wait to see you at <strong>{{ $product->name }}</strong>! If you have any questions or need assistance, please contact our support team.</p>

<p>Have an amazing time at the event!</p>

<p>Best regards,<br>
{{ config('app.name') }} Team</p>
@endsection

Buat template untuk payment reminder di resources/views/emails/payment-reminder.blade.php:

@extends('emails.layout')

@section('content')
<h2>⏰ Payment Reminder - Don't Miss Out!</h2>

<p>Hi {{ $customer->name }},</p>

@if(isset($timeLeft['expired']) && $timeLeft['expired'])
    <p style="color: #dc3545;"><strong>Your order has expired.</strong> But don't worry, you can still book tickets if they're available!</p>
@else
    <p>We noticed you haven't completed your payment yet. Your tickets for <strong>{{ $product->name }}</strong> are still reserved, but time is running out!</p>

    @if($isUrgent)
        <div style="background-color: #fff3cd; border: 1px solid #ffeeba; padding: 15px; border-radius: 8px; margin: 20px 0;">
            <p style="color: #856404; margin: 0;"><strong>⚠️ Urgent:</strong> Only <strong>{{ $timeLeft['hours'] }} hours and {{ $timeLeft['minutes'] }} minutes</strong> left to complete your payment!</p>
        </div>
    @else
        <p>You have <strong>{{ $timeLeft['hours'] }} hours and {{ $timeLeft['minutes'] }} minutes</strong> remaining to complete your payment.</p>
    @endif
@endif

<div class="order-info">
    <h3>Order Summary</h3>
    <div class="info-row">
        <span class="info-label">Order Number:</span>
        <span class="info-value"><strong>{{ $order->order_number }}</strong></span>
    </div>
    <div class="info-row">
        <span class="info-label">Event:</span>
        <span class="info-value">{{ $product->name }}</span>
    </div>
    <div class="info-row">
        <span class="info-label">Date & Time:</span>
        <span class="info-value">{{ $product->event_date->format('l, d M Y') }} at {{ $product->event_time->format('H:i') }}</span>
    </div>
    <div class="info-row">
        <span class="info-label">Quantity:</span>
        <span class="info-value">{{ $order->quantity }} ticket(s)</span>
    </div>
    <div class="info-row">
        <span class="info-label">Total Amount:</span>
        <span class="info-value"><strong>Rp {{ number_format($order->final_amount, 0, ',', '.') }}</strong></span>
    </div>
</div>

@if(!isset($timeLeft['expired']) || !$timeLeft['expired'])
    <div style="text-align: center; margin: 30px 0;">
        <a href="{{ $paymentUrl }}" class="btn btn-warning">Complete Payment Now</a>
    </div>

    <p><strong>Why complete your payment now?</strong></p>
    <ul style="margin-left: 20px;">
        <li>Secure your spot at this amazing event</li>
        <li>Avoid disappointment if tickets sell out</li>
        <li>Receive instant confirmation and digital tickets</li>
    </ul>
@else
    <div style="text-align: center; margin: 30px 0;">
        <a href="{{ route('products.show', $product) }}" class="btn">Book New Tickets</a>
    </div>
@endif

<p>If you're having trouble with payment or have any questions, our support team is here to help!</p>

<p>Don't miss out on this incredible event!</p>

<p>Best regards,<br>
{{ config('app.name') }} Team</p>
@endsection

Membuat Event Listeners

Generate listeners untuk handle events dan send emails:

php artisan make:listener SendOrderConfirmation --event=OrderCreated
php artisan make:listener SendOrderStatusUpdate --event=OrderStatusChanged
php artisan make:listener SendPaymentConfirmation --event=PaymentReceived
php artisan make:listener SendPaymentReminder --event=PaymentReminder

Edit app/Listeners/SendOrderConfirmation.php:

<?php

namespace App\\Listeners;

use App\\Events\\OrderCreated;
use App\\Mail\\OrderConfirmation;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Queue\\InteractsWithQueue;
use Illuminate\\Support\\Facades\\Mail;
use Illuminate\\Support\\Facades\\Log;

class SendOrderConfirmation implements ShouldQueue
{
    use InteractsWithQueue;

    public function handle(OrderCreated $event): void
    {
        try {
            Mail::to($event->order->customer->email)
                ->send(new OrderConfirmation($event->order));

            Log::info('Order confirmation email sent', [
                'order_id' => $event->order->id,
                'customer_email' => $event->order->customer->email
            ]);
        } catch (\\Exception $e) {
            Log::error('Failed to send order confirmation email', [
                'order_id' => $event->order->id,
                'error' => $e->getMessage()
            ]);
        }
    }
}

Edit app/Listeners/SendOrderStatusUpdate.php:

<?php

namespace App\\Listeners;

use App\\Events\\OrderStatusChanged;
use App\\Mail\\OrderStatusUpdate;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Queue\\InteractsWithQueue;
use Illuminate\\Support\\Facades\\Mail;
use Illuminate\\Support\\Facades\\Log;

class SendOrderStatusUpdate implements ShouldQueue
{
    use InteractsWithQueue;

    public function handle(OrderStatusChanged $event): void
    {
        try {
            // Only send email for certain status changes
            $notifiableStatuses = ['paid', 'cancelled', 'refunded'];

            if (in_array($event->newStatus, $notifiableStatuses)) {
                Mail::to($event->order->customer->email)
                    ->send(new OrderStatusUpdate(
                        $event->order,
                        $event->previousStatus,
                        $event->newStatus
                    ));

                Log::info('Order status update email sent', [
                    'order_id' => $event->order->id,
                    'status_change' => $event->previousStatus . ' -> ' . $event->newStatus,
                    'customer_email' => $event->order->customer->email
                ]);
            }
        } catch (\\Exception $e) {
            Log::error('Failed to send order status update email', [
                'order_id' => $event->order->id,
                'error' => $e->getMessage()
            ]);
        }
    }
}

Edit app/Listeners/SendPaymentConfirmation.php:

<?php

namespace App\\Listeners;

use App\\Events\\PaymentReceived;
use App\\Mail\\PaymentConfirmation;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Queue\\InteractsWithQueue;
use Illuminate\\Support\\Facades\\Mail;
use Illuminate\\Support\\Facades\\Log;

class SendPaymentConfirmation implements ShouldQueue
{
    use InteractsWithQueue;

    public function handle(PaymentReceived $event): void
    {
        try {
            Mail::to($event->order->customer->email)
                ->send(new PaymentConfirmation($event->order));

            Log::info('Payment confirmation email sent', [
                'order_id' => $event->order->id,
                'customer_email' => $event->order->customer->email,
                'amount' => $event->order->final_amount
            ]);
        } catch (\\Exception $e) {
            Log::error('Failed to send payment confirmation email', [
                'order_id' => $event->order->id,
                'error' => $e->getMessage()
            ]);
        }
    }
}

Edit app/Listeners/SendPaymentReminder.php:

<?php

namespace App\\Listeners;

use App\\Events\\PaymentReminder;
use App\\Mail\\PaymentReminderMail;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Queue\\InteractsWithQueue;
use Illuminate\\Support\\Facades\\Mail;
use Illuminate\\Support\\Facades\\Log;

class SendPaymentReminder implements ShouldQueue
{
    use InteractsWithQueue;

    public function handle(PaymentReminder $event): void
    {
        try {
            // Only send reminder if order is still pending
            if ($event->order->status === 'pending' && $event->order->payment_status === 'unpaid') {
                Mail::to($event->order->customer->email)
                    ->send(new PaymentReminderMail($event->order));

                Log::info('Payment reminder email sent', [
                    'order_id' => $event->order->id,
                    'customer_email' => $event->order->customer->email
                ]);
            }
        } catch (\\Exception $e) {
            Log::error('Failed to send payment reminder email', [
                'order_id' => $event->order->id,
                'error' => $e->getMessage()
            ]);
        }
    }
}

Register Events dan Listeners

Edit app/Providers/EventServiceProvider.php:

<?php

namespace App\\Providers;

use App\\Events\\OrderCreated;
use App\\Events\\OrderStatusChanged;
use App\\Events\\PaymentReceived;
use App\\Events\\PaymentReminder;
use App\\Listeners\\SendOrderConfirmation;
use App\\Listeners\\SendOrderStatusUpdate;
use App\\Listeners\\SendPaymentConfirmation;
use App\\Listeners\\SendPaymentReminder;
use Illuminate\\Auth\\Events\\Registered;
use Illuminate\\Auth\\Listeners\\SendEmailVerificationNotification;
use Illuminate\\Foundation\\Support\\Providers\\EventServiceProvider as ServiceProvider;
use Illuminate\\Support\\Facades\\Event;

class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        Registered::class => [
            SendEmailVerificationNotification::class,
        ],

        OrderCreated::class => [
            SendOrderConfirmation::class,
        ],

        OrderStatusChanged::class => [
            SendOrderStatusUpdate::class,
        ],

        PaymentReceived::class => [
            SendPaymentConfirmation::class,
        ],

        PaymentReminder::class => [
            SendPaymentReminder::class,
        ],
    ];

    public function boot(): void
    {
        //
    }

    public function shouldDiscoverEvents(): bool
    {
        return false;
    }
}

Update Model Order untuk Trigger Events

Edit app/Models/Order.php untuk add event dispatching:

use App\\Events\\OrderCreated;
use App\\Events\\OrderStatusChanged;
use App\\Events\\PaymentReceived;

class Order extends Model
{
    // ... existing code

    protected static function boot()
    {
        parent::boot();

        static::created(function ($order) {
            OrderCreated::dispatch($order);
        });

        static::updating(function ($order) {
            if ($order->isDirty('status')) {
                $previousStatus = $order->getOriginal('status');
                $newStatus = $order->status;

                // Dispatch status change event after update
                static::updated(function ($updatedOrder) use ($previousStatus, $newStatus) {
                    OrderStatusChanged::dispatch($updatedOrder, $previousStatus, $newStatus);

                    // If payment just confirmed, dispatch payment received event
                    if ($newStatus === 'paid' && $previousStatus !== 'paid') {
                        PaymentReceived::dispatch($updatedOrder);
                    }
                });
            }
        });
    }

    // ... rest of the model
}

Update MidtransService untuk Trigger Events

Edit app/Services/MidtransService.php untuk dispatch events ketika payment status berubah:

use App\\Events\\PaymentReceived;

private function updateOrderStatus(Order $order, string $transactionStatus, ?string $fraudStatus, $notification): void
{
    // ... existing code for updating payment details

    $wasUnpaid = $order->payment_status === 'unpaid';

    switch ($transactionStatus) {
        case 'capture':
            if ($fraudStatus == 'accept') {
                $order->update([
                    'status' => 'paid',
                    'payment_status' => 'paid',
                    'payment_date' => now(),
                    'payment_method' => $notification->payment_type,
                    'payment_reference' => $notification->transaction_id,
                    'payment_details' => $paymentDetails,
                ]);

                if ($wasUnpaid) {
                    PaymentReceived::dispatch($order);
                }
            }
            break;

        case 'settlement':
            $order->update([
                'status' => 'paid',
                'payment_status' => 'paid',
                'payment_date' => now(),
                'payment_method' => $notification->payment_type,
                'payment_reference' => $notification->transaction_id,
                'payment_details' => $paymentDetails,
            ]);

            if ($wasUnpaid) {
                PaymentReceived::dispatch($order);
            }
            break;

        // ... rest of the cases
    }
}

Membuat Command untuk Payment Reminders

Buat artisan command untuk send payment reminders:

php artisan make:command SendPaymentReminders

Edit app/Console/Commands/SendPaymentReminders.php:

<?php

namespace App\\Console\\Commands;

use App\\Models\\Order;
use App\\Events\\PaymentReminder;
use Carbon\\Carbon;
use Illuminate\\Console\\Command;

class SendPaymentReminders extends Command
{
    protected $signature = 'reminders:payment {--hours=6}';
    protected $description = 'Send payment reminder emails for pending orders';

    public function handle()
    {
        $hours = $this->option('hours');
        $reminderTime = Carbon::now()->subHours($hours);

        $pendingOrders = Order::where('status', 'pending')
            ->where('payment_status', 'unpaid')
            ->where('created_at', '<=', $reminderTime)
            ->where('created_at', '>=', Carbon::now()->subHours(24)) // Not older than 24 hours
            ->whereDoesntHave('emailLogs', function ($query) use ($hours) {
                $query->where('type', 'payment_reminder')
                      ->where('sent_at', '>=', Carbon::now()->subHours($hours));
            })
            ->get();

        $this->info("Found {$pendingOrders->count()} orders for payment reminder");

        foreach ($pendingOrders as $order) {
            PaymentReminder::dispatch($order);
            $this->line("Reminder sent for order: {$order->order_number}");
        }

        $this->info('Payment reminders sent successfully!');
    }
}

Setup Queue untuk Email Processing

Untuk production environment, setup queue system untuk handle email sending asynchronously. Update .env:

QUEUE_CONNECTION=database

Generate queue table:

php artisan queue:table
php artisan migrate

Jalankan queue worker:

php artisan queue:work

Schedule Payment Reminders

Add payment reminder schedule di app/Console/Kernel.php:

protected function schedule(Schedule $schedule): void
{
    // Send payment reminders every 6 hours for orders older than 6 hours
    $schedule->command('reminders:payment --hours=6')
             ->everySixHours()
             ->withoutOverlapping();

    // Send urgent reminders every hour for orders older than 18 hours
    $schedule->command('reminders:payment --hours=18')
             ->hourly()
             ->withoutOverlapping();
}

Testing Email System

Buat command untuk test email templates:

php artisan make:command TestEmails

Edit app/Console/Commands/TestEmails.php:

<?php

namespace App\\Console\\Commands;

use App\\Mail\\OrderConfirmation;
use App\\Mail\\PaymentConfirmation;
use App\\Mail\\PaymentReminderMail;
use App\\Models\\Order;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\Mail;

class TestEmails extends Command
{
    protected $signature = 'email:test {type} {order_id} {--to=}';
    protected $description = 'Test email templates';

    public function handle()
    {
        $type = $this->argument('type');
        $orderId = $this->argument('order_id');
        $to = $this->option('to') ?: '[email protected]';

        $order = Order::findOrFail($orderId);

        switch ($type) {
            case 'confirmation':
                Mail::to($to)->send(new OrderConfirmation($order));
                break;
            case 'payment':
                Mail::to($to)->send(new PaymentConfirmation($order));
                break;
            case 'reminder':
                Mail::to($to)->send(new PaymentReminderMail($order));
                break;
            default:
                $this->error('Unknown email type. Use: confirmation, payment, or reminder');
                return;
        }

        $this->info("Test email sent to {$to}");
    }
}

Test dengan command:

php artisan email:test confirmation 1 [email protected]

Dengan email notification system yang comprehensive ini, customers akan selalu terinformed tentang status order mereka, dan website kamu akan terlihat lebih professional dan trustworthy. System ini scalable dan bisa easily dikustomisasi untuk menambahkan jenis notification lain sesuai kebutuhan bisnis!

🎉 Selamat! Tutorial Website Booking Event Ticket Selesai

Kamu telah berhasil membangun aplikasi web yang production-ready dengan Laravel 12, Filament, Spatie Permission, dan Midtrans. Skills yang kamu kuasai - dari authentication, payment integration, email system, hingga admin panel development - adalah exactly what companies butuhkan untuk remote web developer positions.

Project ini bukan hanya tutorial, tapi portfolio piece berkualitas tinggi yang bisa showcase technical abilities kamu ke potential employers worldwide. Web development skills dengan Laravel ecosystem yang kamu miliki sekarang membuka pintu untuk remote work opportunities dengan compensation yang kompetitif.

🚀 Siap untuk Level Up? Lanjutkan dengan BuildWithAngga!

Kalau kamu excited dengan progress yang sudah dicapai dan ingin become professional web developer, BuildWithAngga adalah platform pembelajaran coding terpercaya yang akan accelerate career journey kamu.

✨ 12+ Benefits Belajar di BuildWithAngga:

  • Portfolio Berkualitas Tinggi - Build impressive projects yang stand out ke employers • Akses Selamanya - Lifetime access untuk continuous learning tanpa batas waktu • Mentor Industry Expert - Belajar langsung dari professional dengan real-world experience • Project-Based Learning - Hands-on approach dengan aplikasi yang actually useful • Community Support - Network dengan fellow developers dan alumni untuk collaboration • Industry-Relevant Curriculum - Updated dengan latest tech trends dan market demands • Career Guidance - Support untuk job search, interview prep, dan salary negotiation • Flexible Schedule - Learn at your own pace tanpa mengganggu commitments lain • Certificate Recognition - Credentials yang add value ke professional profile • Live Interactive Sessions - Direct Q&A dengan mentors untuk deeper understanding • Job Placement Assistance - Connections dengan hiring partners dan job opportunities • Comprehensive Learning Path - From beginner to advanced dengan structured progression

💼 Remote Work Opportunities Menanti

Dengan Laravel expertise dan full-stack development skills yang kamu miliki, tersedia berbagai remote positions:

Full-Stack Developer - SaaS applications, e-commerce platforms, business systems Laravel Specialist - Agencies dan companies dengan Laravel-heavy tech stack

Freelance Developer - Complete web solutions dari conception hingga deployment API Developer - Backend services untuk mobile apps dan microservices architecture

🎯 Investment untuk Future Success

Learning dengan BuildWithAngga bukan cuma about acquiring new skills, tapi investment untuk long-term career success. Web development field reward continuous learners, dan dengan quality education plus mentorship, kamu akan stay ahead of competition dan unlock unlimited remote work possibilities.

Ready untuk take next step? Explore programs di BuildWithAngga dan transform passion untuk coding menjadi profitable remote career. Portfolio yang strong + skills yang relevant + network yang supportive = recipe for success sebagai professional web developer.

Your journey baru saja dimulai - mari build something amazing together! 🚀