Tutorial Laravel 12 Vue 3 Filament 4 Bikin Website Booking Dokter dan Hospital

Di tahun 2026, kalau ada yang tanya "stack apa yang cocok untuk bikin aplikasi web profesional?", jawaban saya konsisten: Laravel + Vue + Filament.

Bukan karena ini stack paling "hype". Bukan juga karena saya fanboy salah satu teknologi.

Tapi karena ini stack paling "passssss".

Maksudnya? Kamu bisa fokus bikin fitur, bukan sibuk setup dan konfigurasi yang endless. Kamu bisa deliver project ke klien, bukan terjebak di tutorial hell yang tidak ada ujungnya.

Kenapa Kombinasi Ini Powerful

Mari saya jelaskan satu per satu:

Laravel 12 — Backend yang mature. Dokumentasi lengkap. Komunitas besar. Fitur baru seperti Automatic Eager Loading dan native health checks bikin development makin smooth. Mau bikin API? Tinggal pakai Sanctum. Mau queue? Sudah built-in. Mau testing? PHPUnit siap pakai.

Vue 3 — Frontend yang intuitive. Composition API sudah jadi standar, bikin code lebih terorganisir. Dengan <script setup> syntax, boilerplate berkurang drastis. Pinia untuk state management yang simple tapi powerful. TypeScript support yang mature.

Filament 4 — Admin panel yang "langsung jadi". Ini yang sering diremehkan padahal game-changer. Bayangkan bikin dashboard admin dengan CRUD lengkap dalam hitungan jam, bukan minggu. Versi 4 bahkan 2.38x lebih cepat dari sebelumnya.

Ketiganya punya satu kesamaan: Developer Experience yang luar biasa.

Kenapa Project Booking Hospital

Saya sengaja pilih project booking dokter dan rumah sakit karena beberapa alasan:

AlasanPenjelasan
RelevanSistem kesehatan digital makin penting pasca pandemi
Kompleksitas PasAda relasi antar data, jadwal, status — cukup menantang tapi tidak overwhelming
Real-WorldBisa jadi portfolio atau bahkan dijual sebagai SaaS
ScalableFondasi yang bisa dikembangkan (payment, teleconsultation, dll)

Yang Akan Kita Bangun

PROJECT STRUCTURE:

Frontend (Vue 3) — patients.hospital.test
├── / (landing page)
├── /doctors (list dokter dengan filter)
├── /doctors/:id (profil dokter)
├── /booking/:doctorId (form booking)
├── /my-appointments (riwayat pasien)
├── /login
└── /register

Admin Panel (Filament 4) — admin.hospital.test
├── /admin (dashboard dengan statistik)
├── /admin/doctors (kelola dokter)
├── /admin/schedules (kelola jadwal)
├── /admin/appointments (kelola booking)
├── /admin/patients (data pasien)
└── /admin/specializations (spesialisasi)

Backend (Laravel 12)
├── RESTful API untuk frontend
├── Authentication dengan Sanctum
├── Business logic (quota, validasi jadwal)
└── Queue untuk notifications

Prerequisites

Sebelum mulai, pastikan sudah terinstall di komputer kamu:

  • PHP 8.2+ (recommended 8.4 untuk performa terbaik)
  • Composer 2.x
  • Node.js 20+ dan npm
  • MySQL 8.x atau PostgreSQL
  • Code editor (VS Code recommended dengan extensions PHP Intelephense, Volar untuk Vue)

Kalau semua sudah siap, mari kita mulai.


Bagian 2: Setup Project Laravel 12

Install Laravel 12

Buka terminal dan jalankan:

# Install Laravel 12 via Composer
composer create-project laravel/laravel hospital-booking "12.*"

cd hospital-booking

Atau kalau sudah punya Laravel Installer:

laravel new hospital-booking

Installer akan tanya beberapa pilihan. Untuk project ini:

  • Starter kit: None (kita pakai Filament untuk admin)
  • Testing framework: PHPUnit
  • Database: MySQL

Konfigurasi Environment

Buka file .env dan sesuaikan:

APP_NAME="Hospital Booking"
APP_URL=http://hospital.test

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

# Queue untuk background jobs (notifications, dll)
QUEUE_CONNECTION=database

# Sanctum untuk API auth
SANCTUM_STATEFUL_DOMAINS=localhost,hospital.test

Buat database-nya:

mysql -u root -p
CREATE DATABASE hospital_booking;
exit;

Install Dependencies

# Install Sanctum untuk API Authentication
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\\Sanctum\\SanctumServiceProvider"

# Install Filament 4
composer require filament/filament:"^4.0"
php artisan filament:install --panels

# Saat ditanya, pilih:
# - Panel ID: admin
# - Path: admin

Setelah Filament terinstall, buat user admin:

php artisan make:filament-user

# Isi:
# Name: Admin
# Email: [email protected]
# Password: password

Konfigurasi Sanctum untuk API

Edit config/sanctum.php:

'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
    '%s%s',
    'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
    env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : ''
))),

Edit app/Http/Kernel.php atau di Laravel 12, pastikan middleware sudah dikonfigurasi di bootstrap/app.php:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->statefulApi();
})

Struktur Folder Project

Setelah setup, struktur folder kita seperti ini:

hospital-booking/
├── app/
│   ├── Filament/              # Admin panel (auto-generated)
│   │   ├── Resources/         # CRUD resources
│   │   ├── Pages/             # Custom pages
│   │   └── Widgets/           # Dashboard widgets
│   ├── Http/
│   │   ├── Controllers/
│   │   │   └── Api/           # API Controllers (kita buat)
│   │   └── Requests/          # Form validation
│   ├── Models/                # Eloquent Models
│   └── Services/              # Business logic (optional)
├── database/
│   ├── migrations/            # Database structure
│   ├── factories/             # Test data generators
│   └── seeders/               # Initial data
├── routes/
│   ├── api.php               # API routes
│   └── web.php               # Web routes (Filament)
└── frontend/                  # Vue 3 (kita buat terpisah)

Test Installation

Jalankan development server:

php artisan serve

Buka browser:

  • http://localhost:8000 — Laravel welcome page
  • http://localhost:8000/admin — Filament login page

Login dengan kredensial admin yang tadi dibuat. Kalau berhasil masuk ke dashboard Filament, setup sukses.

💡 TIPS LARAVEL 12:

Di Laravel 12, UUID default-nya pakai v7 (time-ordered) bukan v4.
UUIDv7 lebih bagus untuk database indexing karena sequential.

Kalau karena alasan tertentu butuh v4, gunakan:
use Illuminate\\Database\\Eloquent\\Concerns\\HasVersion4Uuids as HasUuids;

Tapi untuk project baru, stick dengan v7.

Bagian 3: Database Design & Migrations

Sebelum nulis code, kita perlu design database yang solid. Ini fondasi yang menentukan kemudahan development ke depannya.

Planning: Apa Saja yang Dibutuhkan?

Untuk sistem booking dokter, kita butuh:

EntityFungsi
UsersSemua pengguna (admin, dokter, pasien)
SpecializationsSpesialisasi dokter (Kardiologi, Neurologi, dll)
DoctorsData detail dokter (terhubung ke user)
SchedulesJadwal praktik mingguan dokter
AppointmentsBooking dari pasien

ERD (Entity Relationship Diagram)

┌──────────────────┐           ┌──────────────────┐
│      USERS       │           │  SPECIALIZATIONS │
├──────────────────┤           ├──────────────────┤
│ id               │           │ id               │
│ name             │           │ name             │
│ email            │           │ description      │
│ password         │           │ icon             │
│ role             │           │ is_active        │
│ phone            │           └────────┬─────────┘
│ address          │                    │
│ date_of_birth    │                    │ 1
│ gender           │                    │
└────────┬─────────┘                    N
         │                       ┌──────┴─────────┐
         │ 1 (user)              │    DOCTORS     │
         │                       ├────────────────┤
         │                       │ id             │
         │                       │ user_id (FK)───┼───┐
         │                       │ specialization │   │
         │                       │ _id (FK)       │   │
         │                       │ license_number │   │
         │                       │ experience_yrs │   │
         │                       │ consultation_fee   │
         │                       │ bio            │   │
         │                       │ photo          │   │
         │                       │ is_available   │   │
         │                       └───────┬────────┘   │
         │                               │            │
         │ 1 (as patient)                │ 1          │
         │                               │            │
         N                               N            │
┌────────┴─────────┐             ┌───────┴────────┐   │
│   APPOINTMENTS   │             │   SCHEDULES    │   │
├──────────────────┤             ├────────────────┤   │
│ id               │             │ id             │   │
│ booking_code     │             │ doctor_id (FK) │   │
│ patient_id (FK)──┼─────────────│ day_of_week    │   │
│ doctor_id (FK)───┼─────────────│ start_time     │   │
│ schedule_id (FK) │             │ end_time       │   │
│ appointment_date │             │ quota          │   │
│ complaint        │             │ is_active      │   │
│ status           │             └────────────────┘   │
│ notes            │                                  │
└──────────────────┘                                  │
         │                                            │
         └────────────────────────────────────────────┘

RELASI:
• User (1) ──── (1) Doctor (satu user bisa jadi satu dokter)
• User (1) ──── (N) Appointments (satu pasien bisa banyak booking)
• Specialization (1) ──── (N) Doctors
• Doctor (1) ──── (N) Schedules
• Doctor (1) ──── (N) Appointments
• Schedule (1) ──── (N) Appointments

Migration 1: Modifikasi Users Table

Kita tambahkan kolom untuk role dan data personal:

php artisan make:migration add_profile_fields_to_users_table
<?php

// database/migrations/xxxx_add_profile_fields_to_users_table.php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            // Role: admin, doctor, atau patient
            $table->enum('role', ['admin', 'doctor', 'patient'])
                  ->default('patient')
                  ->after('email');

            // Data personal
            $table->string('phone', 20)->nullable()->after('role');
            $table->text('address')->nullable()->after('phone');
            $table->date('date_of_birth')->nullable()->after('address');
            $table->enum('gender', ['male', 'female'])->nullable()->after('date_of_birth');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn(['role', 'phone', 'address', 'date_of_birth', 'gender']);
        });
    }
};

Kenapa pakai single users table untuk semua role?

Ini pendekatan yang disebut Single Table Inheritance. Lebih simple karena:

  • Authentication satu tempat
  • Tidak perlu multiple login systems
  • Data spesifik dokter disimpan di tabel doctors terpisah

Migration 2: Specializations Table

php artisan make:migration create_specializations_table
<?php

// database/migrations/xxxx_create_specializations_table.php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('specializations', function (Blueprint $table) {
            $table->id();
            $table->string('name');              // Kardiologi, Neurologi, dll
            $table->text('description')->nullable();
            $table->string('icon')->nullable();   // Nama icon atau URL gambar
            $table->boolean('is_active')->default(true);
            $table->timestamps();
        });
    }

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

Migration 3: Doctors Table

php artisan make:migration create_doctors_table
<?php

// database/migrations/xxxx_create_doctors_table.php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('doctors', function (Blueprint $table) {
            $table->id();

            // Relasi ke user (one-to-one)
            $table->foreignId('user_id')
                  ->constrained()
                  ->cascadeOnDelete();

            // Relasi ke spesialisasi
            $table->foreignId('specialization_id')
                  ->constrained()
                  ->cascadeOnDelete();

            // Data profesional
            $table->string('license_number')->unique();  // Nomor STR
            $table->integer('experience_years')->default(0);
            $table->integer('consultation_fee')->default(0);  // Dalam rupiah

            // Profile
            $table->text('bio')->nullable();
            $table->string('photo')->nullable();

            // Status
            $table->boolean('is_available')->default(true);

            $table->timestamps();
        });
    }

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

Kenapa consultation_fee pakai integer bukan decimal?

Untuk mata uang Rupiah yang tidak pakai sen, integer lebih simple. Rp 150.000 disimpan sebagai 150000. Formatting dilakukan di level aplikasi.

Migration 4: Schedules Table

php artisan make:migration create_schedules_table
<?php

// database/migrations/xxxx_create_schedules_table.php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('schedules', function (Blueprint $table) {
            $table->id();

            // Relasi ke dokter
            $table->foreignId('doctor_id')
                  ->constrained()
                  ->cascadeOnDelete();

            // Hari dalam seminggu
            $table->enum('day_of_week', [
                'monday', 'tuesday', 'wednesday',
                'thursday', 'friday', 'saturday', 'sunday'
            ]);

            // Jam praktik
            $table->time('start_time');
            $table->time('end_time');

            // Kuota per slot (mencegah overbooking)
            $table->integer('quota')->default(10);

            // Status
            $table->boolean('is_active')->default(true);

            $table->timestamps();

            // Constraint: satu dokter tidak bisa punya jadwal duplikat
            // di hari dan jam yang sama
            $table->unique(['doctor_id', 'day_of_week', 'start_time']);
        });
    }

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

Konsep Schedule vs Appointment:

  • Schedule = Template jadwal mingguan (Senin 09:00-12:00, Rabu 13:00-16:00)
  • Appointment = Booking aktual dengan tanggal spesifik (Senin, 15 Maret 2026)

Dengan pemisahan ini, dokter cukup set jadwal sekali, dan sistem otomatis tahu kapan mereka tersedia.

Migration 5: Appointments Table

php artisan make:migration create_appointments_table
<?php

// database/migrations/xxxx_create_appointments_table.php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('appointments', function (Blueprint $table) {
            $table->id();

            // Kode booking unik untuk tracking
            $table->string('booking_code', 20)->unique();

            // Relasi
            $table->foreignId('patient_id')
                  ->constrained('users')  // FK ke users table
                  ->cascadeOnDelete();

            $table->foreignId('doctor_id')
                  ->constrained()
                  ->cascadeOnDelete();

            $table->foreignId('schedule_id')
                  ->constrained()
                  ->cascadeOnDelete();

            // Detail booking
            $table->date('appointment_date');
            $table->text('complaint');  // Keluhan pasien

            // Status workflow
            $table->enum('status', [
                'pending',     // Menunggu konfirmasi
                'confirmed',   // Dikonfirmasi admin/dokter
                'completed',   // Selesai konsultasi
                'cancelled'    // Dibatalkan
            ])->default('pending');

            // Catatan dari dokter (setelah konsultasi)
            $table->text('notes')->nullable();

            $table->timestamps();

            // Index untuk query yang sering dilakukan
            $table->index(['doctor_id', 'appointment_date']);
            $table->index(['patient_id', 'status']);
        });
    }

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

Kenapa ada booking_code?

Untuk memudahkan pasien cek status tanpa perlu login. Mereka cukup input kode seperti HSP-20260315-A7X9 di halaman tracking.

Jalankan Migrations

php artisan migrate

Output yang diharapkan:

INFO  Running migrations.

2024_01_01_000000_create_users_table ................ DONE
2024_01_01_000001_create_cache_table ................ DONE
2024_01_01_000002_create_jobs_table ................. DONE
xxxx_add_profile_fields_to_users_table .............. DONE
xxxx_create_specializations_table ................... DONE
xxxx_create_doctors_table ........................... DONE
xxxx_create_schedules_table ......................... DONE
xxxx_create_appointments_table ...................... DONE

Seeder untuk Data Awal

Buat seeder untuk spesialisasi:

php artisan make:seeder SpecializationSeeder
<?php

// database/seeders/SpecializationSeeder.php

namespace Database\\Seeders;

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

class SpecializationSeeder extends Seeder
{
    public function run(): void
    {
        $specializations = [
            [
                'name' => 'Umum',
                'description' => 'Dokter umum untuk pemeriksaan kesehatan dasar',
                'icon' => 'stethoscope',
            ],
            [
                'name' => 'Kardiologi',
                'description' => 'Spesialis jantung dan pembuluh darah',
                'icon' => 'heart',
            ],
            [
                'name' => 'Neurologi',
                'description' => 'Spesialis saraf dan otak',
                'icon' => 'brain',
            ],
            [
                'name' => 'Ortopedi',
                'description' => 'Spesialis tulang dan sendi',
                'icon' => 'bone',
            ],
            [
                'name' => 'Pediatri',
                'description' => 'Spesialis kesehatan anak',
                'icon' => 'baby',
            ],
            [
                'name' => 'Dermatologi',
                'description' => 'Spesialis kulit dan kelamin',
                'icon' => 'hand',
            ],
            [
                'name' => 'THT',
                'description' => 'Spesialis telinga, hidung, dan tenggorokan',
                'icon' => 'ear',
            ],
            [
                'name' => 'Mata',
                'description' => 'Spesialis kesehatan mata',
                'icon' => 'eye',
            ],
        ];

        foreach ($specializations as $spec) {
            Specialization::create($spec);
        }
    }
}

Jalankan seeder:

php artisan db:seed --class=SpecializationSeeder
💡 TIPS DATABASE DESIGN:

1. Selalu pakai foreign key constraints
   → Database menjaga integritas data
   → Tidak bisa delete dokter yang masih punya appointment

2. Tambahkan index untuk kolom yang sering di-query
   → appointment_date sering difilter
   → status sering difilter

3. Pakai enum untuk nilai yang terbatas
   → status hanya bisa pending/confirmed/completed/cancelled
   → Tidak bisa diisi nilai random

4. Soft deletes opsional
   → Untuk data yang butuh audit trail
   → Di tutorial ini kita pakai hard delete untuk simplicity

Di bagian selanjutnya, kita akan membuat Eloquent Models dengan relationships yang proper, accessors, scopes, dan helper methods.


Bagian 4: Eloquent Models & Relationships

Sekarang kita buat Models yang akan menjadi jembatan antara aplikasi dan database. Di Laravel, Models bukan sekadar representasi tabel — mereka juga tempat untuk relationships, accessors, scopes, dan business logic ringan.

Model: User

# User model sudah ada, kita edit saja
<?php

// app/Models/User.php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Notifications\\Notifiable;
use Laravel\\Sanctum\\HasApiTokens;

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

    protected $fillable = [
        'name',
        'email',
        'password',
        'role',
        'phone',
        'address',
        'date_of_birth',
        'gender',
    ];

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

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
            'date_of_birth' => 'date',
        ];
    }

    // ==================
    // RELATIONSHIPS
    // ==================

    /**
     * User bisa jadi dokter (one-to-one)
     */
    public function doctor(): HasOne
    {
        return $this->hasOne(Doctor::class);
    }

    /**
     * User sebagai pasien bisa punya banyak appointments
     */
    public function appointments(): HasMany
    {
        return $this->hasMany(Appointment::class, 'patient_id');
    }

    // ==================
    // SCOPES
    // ==================

    /**
     * Filter hanya pasien
     */
    public function scopePatients($query)
    {
        return $query->where('role', 'patient');
    }

    /**
     * Filter hanya dokter
     */
    public function scopeDoctors($query)
    {
        return $query->where('role', 'doctor');
    }

    /**
     * Filter hanya admin
     */
    public function scopeAdmins($query)
    {
        return $query->where('role', 'admin');
    }

    // ==================
    // HELPER METHODS
    // ==================

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

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

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

    // ==================
    // ACCESSORS
    // ==================

    /**
     * Format tanggal lahir untuk display
     */
    public function getFormattedBirthDateAttribute(): ?string
    {
        return $this->date_of_birth?->format('d M Y');
    }

    /**
     * Hitung umur
     */
    public function getAgeAttribute(): ?int
    {
        return $this->date_of_birth?->age;
    }
}

Model: Specialization

php artisan make:model Specialization
<?php

// app/Models/Specialization.php

namespace App\\Models;

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

class Specialization extends Model
{
    use HasFactory;

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

    protected function casts(): array
    {
        return [
            'is_active' => 'boolean',
        ];
    }

    // ==================
    // RELATIONSHIPS
    // ==================

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

    // ==================
    // SCOPES
    // ==================

    public function scopeActive($query)
    {
        return $query->where('is_active', true);
    }
}

Model: Doctor

php artisan make:model Doctor
<?php

// app/Models/Doctor.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 Doctor extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id',
        'specialization_id',
        'license_number',
        'experience_years',
        'consultation_fee',
        'bio',
        'photo',
        'is_available',
    ];

    protected function casts(): array
    {
        return [
            'is_available' => 'boolean',
            'consultation_fee' => 'integer',
            'experience_years' => 'integer',
        ];
    }

    // ==================
    // RELATIONSHIPS
    // ==================

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

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

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

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

    // ==================
    // SCOPES
    // ==================

    /**
     * Hanya dokter yang tersedia
     */
    public function scopeAvailable($query)
    {
        return $query->where('is_available', true);
    }

    /**
     * Filter berdasarkan spesialisasi
     */
    public function scopeBySpecialization($query, int $specializationId)
    {
        return $query->where('specialization_id', $specializationId);
    }

    /**
     * Dengan relasi untuk list (optimized)
     */
    public function scopeWithListRelations($query)
    {
        return $query->with(['user:id,name,email', 'specialization:id,name']);
    }

    // ==================
    // ACCESSORS
    // ==================

    /**
     * Nama lengkap dengan gelar
     */
    public function getFullNameAttribute(): string
    {
        return 'dr. ' . $this->user->name;
    }

    /**
     * Format biaya konsultasi
     */
    public function getFormattedFeeAttribute(): string
    {
        return 'Rp ' . number_format($this->consultation_fee, 0, ',', '.');
    }

    /**
     * URL foto atau placeholder
     */
    public function getPhotoUrlAttribute(): string
    {
        if ($this->photo) {
            return asset('storage/' . $this->photo);
        }
        return asset('images/doctor-placeholder.png');
    }

    // ==================
    // METHODS
    // ==================

    /**
     * Cek apakah dokter punya jadwal aktif
     */
    public function hasActiveSchedules(): bool
    {
        return $this->schedules()->where('is_active', true)->exists();
    }

    /**
     * Ambil jadwal aktif
     */
    public function getActiveSchedules()
    {
        return $this->schedules()
            ->where('is_active', true)
            ->orderByRaw("FIELD(day_of_week, 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday')")
            ->get();
    }
}

Model: Schedule

php artisan make:model Schedule
<?php

// app/Models/Schedule.php

namespace App\\Models;

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

class Schedule extends Model
{
    use HasFactory;

    protected $fillable = [
        'doctor_id',
        'day_of_week',
        'start_time',
        'end_time',
        'quota',
        'is_active',
    ];

    protected function casts(): array
    {
        return [
            'is_active' => 'boolean',
            'quota' => 'integer',
        ];
    }

    // ==================
    // RELATIONSHIPS
    // ==================

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

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

    // ==================
    // SCOPES
    // ==================

    public function scopeActive($query)
    {
        return $query->where('is_active', true);
    }

    public function scopeForDay($query, string $day)
    {
        return $query->where('day_of_week', strtolower($day));
    }

    // ==================
    // ACCESSORS
    // ==================

    /**
     * Format range waktu
     */
    public function getTimeRangeAttribute(): string
    {
        $start = Carbon::parse($this->start_time)->format('H:i');
        $end = Carbon::parse($this->end_time)->format('H:i');
        return "{$start} - {$end}";
    }

    /**
     * Nama hari dalam Bahasa Indonesia
     */
    public function getDayNameAttribute(): string
    {
        $days = [
            'monday'    => 'Senin',
            'tuesday'   => 'Selasa',
            'wednesday' => 'Rabu',
            'thursday'  => 'Kamis',
            'friday'    => 'Jumat',
            'saturday'  => 'Sabtu',
            'sunday'    => 'Minggu',
        ];

        return $days[$this->day_of_week] ?? $this->day_of_week;
    }

    // ==================
    // METHODS
    // ==================

    /**
     * Hitung kuota tersisa untuk tanggal tertentu
     */
    public function getAvailableQuota(string $date): int
    {
        $bookedCount = $this->appointments()
            ->where('appointment_date', $date)
            ->whereNotIn('status', ['cancelled'])
            ->count();

        return max(0, $this->quota - $bookedCount);
    }

    /**
     * Cek apakah masih tersedia di tanggal tertentu
     */
    public function isAvailableOn(string $date): bool
    {
        return $this->getAvailableQuota($date) > 0;
    }

    /**
     * Ambil tanggal terdekat untuk jadwal ini
     */
    public function getNextAvailableDate(): ?Carbon
    {
        $today = Carbon::today();
        $dayOfWeek = $this->day_of_week;

        // Cari tanggal terdekat dengan hari yang sesuai
        $nextDate = $today->copy()->next($dayOfWeek);

        // Kalau hari ini adalah hari jadwal dan masih ada kuota, pakai hari ini
        if ($today->englishDayOfWeek === ucfirst($dayOfWeek)) {
            if ($this->isAvailableOn($today->format('Y-m-d'))) {
                return $today;
            }
        }

        // Cek 4 minggu ke depan
        for ($i = 0; $i < 4; $i++) {
            $checkDate = $nextDate->copy()->addWeeks($i);
            if ($this->isAvailableOn($checkDate->format('Y-m-d'))) {
                return $checkDate;
            }
        }

        return null;
    }
}

Model: Appointment

php artisan make:model Appointment
<?php

// app/Models/Appointment.php

namespace App\\Models;

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

class Appointment extends Model
{
    use HasFactory;

    protected $fillable = [
        'booking_code',
        'patient_id',
        'doctor_id',
        'schedule_id',
        'appointment_date',
        'complaint',
        'status',
        'notes',
    ];

    protected function casts(): array
    {
        return [
            'appointment_date' => 'date',
        ];
    }

    // ==================
    // BOOT
    // ==================

    protected static function booted(): void
    {
        // Auto-generate booking code saat create
        static::creating(function (Appointment $appointment) {
            if (empty($appointment->booking_code)) {
                $appointment->booking_code = self::generateBookingCode();
            }
        });
    }

    /**
     * Generate kode booking unik
     * Format: HSP-YYYYMMDD-XXXX
     */
    public static function generateBookingCode(): string
    {
        $date = now()->format('Ymd');
        $random = strtoupper(Str::random(4));
        $code = "HSP-{$date}-{$random}";

        // Pastikan unik
        while (self::where('booking_code', $code)->exists()) {
            $random = strtoupper(Str::random(4));
            $code = "HSP-{$date}-{$random}";
        }

        return $code;
    }

    // ==================
    // RELATIONSHIPS
    // ==================

    public function patient(): BelongsTo
    {
        return $this->belongsTo(User::class, 'patient_id');
    }

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

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

    // ==================
    // SCOPES
    // ==================

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

    public function scopeConfirmed($query)
    {
        return $query->where('status', 'confirmed');
    }

    public function scopeCompleted($query)
    {
        return $query->where('status', 'completed');
    }

    public function scopeCancelled($query)
    {
        return $query->where('status', 'cancelled');
    }

    public function scopeToday($query)
    {
        return $query->whereDate('appointment_date', today());
    }

    public function scopeUpcoming($query)
    {
        return $query->whereDate('appointment_date', '>=', today())
                     ->whereIn('status', ['pending', 'confirmed']);
    }

    public function scopeForPatient($query, int $patientId)
    {
        return $query->where('patient_id', $patientId);
    }

    public function scopeForDoctor($query, int $doctorId)
    {
        return $query->where('doctor_id', $doctorId);
    }

    // ==================
    // ACCESSORS
    // ==================

    /**
     * Format tanggal appointment
     */
    public function getFormattedDateAttribute(): string
    {
        return $this->appointment_date->translatedFormat('l, d F Y');
    }

    /**
     * Label status dalam bahasa Indonesia
     */
    public function getStatusLabelAttribute(): string
    {
        return match ($this->status) {
            'pending'   => 'Menunggu Konfirmasi',
            'confirmed' => 'Dikonfirmasi',
            'completed' => 'Selesai',
            'cancelled' => 'Dibatalkan',
            default     => $this->status,
        };
    }

    /**
     * Warna badge untuk status
     */
    public function getStatusColorAttribute(): string
    {
        return match ($this->status) {
            'pending'   => 'warning',
            'confirmed' => 'success',
            'completed' => 'primary',
            'cancelled' => 'danger',
            default     => 'secondary',
        };
    }

    // ==================
    // METHODS
    // ==================

    /**
     * Cek apakah bisa dibatalkan
     */
    public function canBeCancelled(): bool
    {
        // Hanya pending dan confirmed yang bisa dibatalkan
        // Dan tanggal belum lewat
        return in_array($this->status, ['pending', 'confirmed'])
            && $this->appointment_date->isFuture();
    }

    /**
     * Batalkan appointment
     */
    public function cancel(): bool
    {
        if (!$this->canBeCancelled()) {
            return false;
        }

        $this->update(['status' => 'cancelled']);
        return true;
    }

    /**
     * Konfirmasi appointment
     */
    public function confirm(): bool
    {
        if ($this->status !== 'pending') {
            return false;
        }

        $this->update(['status' => 'confirmed']);
        return true;
    }

    /**
     * Selesaikan appointment
     */
    public function complete(?string $notes = null): bool
    {
        if ($this->status !== 'confirmed') {
            return false;
        }

        $this->update([
            'status' => 'completed',
            'notes' => $notes,
        ]);
        return true;
    }
}
💡 TIPS ELOQUENT LARAVEL 12:

1. Gunakan typed properties dan return types
   → public function doctor(): BelongsTo
   → IDE autocomplete lebih baik

2. Pisahkan logic ke methods yang descriptive
   → canBeCancelled(), confirm(), complete()
   → Code lebih readable dan testable

3. Pakai accessors untuk formatting
   → getFormattedFeeAttribute()
   → View tetap bersih

4. Scopes untuk query yang sering dipakai
   → scopeAvailable(), scopeToday()
   → Chainable dan reusable

Bagian 5: Filament 4 Admin Panel

Filament adalah game-changer untuk admin panel. Dalam hitungan menit, kita bisa punya CRUD lengkap dengan fitur-fitur seperti filtering, sorting, bulk actions, dan dashboard widgets.

Konfigurasi Panel

Edit file provider yang sudah di-generate:

<?php

// app/Providers/Filament/AdminPanelProvider.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()
            ->colors([
                'primary' => Color::Blue,
                'danger' => Color::Rose,
                'success' => Color::Emerald,
                'warning' => Color::Amber,
            ])
            ->brandName('Hospital Admin')
            ->brandLogo(asset('images/logo.png'))
            ->favicon(asset('favicon.ico'))
            ->navigationGroups([
                'Master Data',
                'Transaksi',
                'Laporan',
            ])
            ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\\\Filament\\\\Resources')
            ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\\\Filament\\\\Pages')
            ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\\\Filament\\\\Widgets')
            ->pages([
                Pages\\Dashboard::class,
            ])
            ->widgets([
                Widgets\\AccountWidget::class,
            ])
            ->middleware([
                EncryptCookies::class,
                AddQueuedCookiesToResponse::class,
                StartSession::class,
                AuthenticateSession::class,
                ShareErrorsFromSession::class,
                VerifyCsrfToken::class,
                SubstituteBindings::class,
                DisableBladeIconComponents::class,
                DispatchServingFilamentEvent::class,
            ])
            ->authMiddleware([
                Authenticate::class,
            ]);
    }
}

Resource: SpecializationResource

Buat resource pertama untuk spesialisasi:

php artisan make:filament-resource Specialization --generate

Edit hasilnya:

<?php

// app/Filament/Resources/SpecializationResource.php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\SpecializationResource\\Pages;
use App\\Models\\Specialization;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;

class SpecializationResource extends Resource
{
    protected static ?string $model = Specialization::class;

    protected static ?string $navigationIcon = 'heroicon-o-academic-cap';
    protected static ?string $navigationLabel = 'Spesialisasi';
    protected static ?string $navigationGroup = 'Master Data';
    protected static ?int $navigationSort = 1;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Informasi Spesialisasi')
                    ->schema([
                        Forms\\Components\\TextInput::make('name')
                            ->label('Nama Spesialisasi')
                            ->required()
                            ->maxLength(100)
                            ->placeholder('Contoh: Kardiologi'),

                        Forms\\Components\\Textarea::make('description')
                            ->label('Deskripsi')
                            ->rows(3)
                            ->placeholder('Deskripsi singkat tentang spesialisasi ini'),

                        Forms\\Components\\TextInput::make('icon')
                            ->label('Icon')
                            ->placeholder('Nama icon Heroicons')
                            ->helperText('Lihat: heroicons.com'),

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

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

                Tables\\Columns\\TextColumn::make('doctors_count')
                    ->label('Jumlah Dokter')
                    ->counts('doctors')
                    ->sortable(),

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

                Tables\\Columns\\TextColumn::make('created_at')
                    ->label('Dibuat')
                    ->dateTime('d M Y')
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                Tables\\Filters\\TernaryFilter::make('is_active')
                    ->label('Status Aktif'),
            ])
            ->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\\ListSpecializations::route('/'),
            'create' => Pages\\CreateSpecialization::route('/create'),
            'edit' => Pages\\EditSpecialization::route('/{record}/edit'),
        ];
    }
}

Resource: DoctorResource

php artisan make:filament-resource Doctor --generate
<?php

// app/Filament/Resources/DoctorResource.php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\DoctorResource\\Pages;
use App\\Filament\\Resources\\DoctorResource\\RelationManagers;
use App\\Models\\Doctor;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;

class DoctorResource extends Resource
{
    protected static ?string $model = Doctor::class;

    protected static ?string $navigationIcon = 'heroicon-o-user-group';
    protected static ?string $navigationLabel = 'Dokter';
    protected static ?string $navigationGroup = 'Master Data';
    protected static ?int $navigationSort = 2;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Informasi Akun')
                    ->description('Data akun login untuk dokter')
                    ->schema([
                        Forms\\Components\\Select::make('user_id')
                            ->label('User Account')
                            ->relationship('user', 'name')
                            ->searchable()
                            ->preload()
                            ->required()
                            ->createOptionForm([
                                Forms\\Components\\TextInput::make('name')
                                    ->label('Nama Lengkap')
                                    ->required(),
                                Forms\\Components\\TextInput::make('email')
                                    ->email()
                                    ->required()
                                    ->unique('users', 'email'),
                                Forms\\Components\\TextInput::make('password')
                                    ->password()
                                    ->required()
                                    ->minLength(8),
                                Forms\\Components\\TextInput::make('phone')
                                    ->label('No. Telepon')
                                    ->tel(),
                            ])
                            ->createOptionUsing(function (array $data) {
                                $data['role'] = 'doctor';
                                $data['password'] = bcrypt($data['password']);
                                return \\App\\Models\\User::create($data)->id;
                            }),
                    ]),

                Forms\\Components\\Section::make('Informasi Profesional')
                    ->schema([
                        Forms\\Components\\Select::make('specialization_id')
                            ->label('Spesialisasi')
                            ->relationship('specialization', 'name')
                            ->searchable()
                            ->preload()
                            ->required(),

                        Forms\\Components\\TextInput::make('license_number')
                            ->label('Nomor STR')
                            ->required()
                            ->unique(ignoreRecord: true)
                            ->placeholder('Contoh: 31.2.1.xxx.x.xx.xxxxx'),

                        Forms\\Components\\TextInput::make('experience_years')
                            ->label('Pengalaman (Tahun)')
                            ->numeric()
                            ->minValue(0)
                            ->maxValue(50)
                            ->default(0)
                            ->suffix('tahun'),

                        Forms\\Components\\TextInput::make('consultation_fee')
                            ->label('Biaya Konsultasi')
                            ->numeric()
                            ->prefix('Rp')
                            ->required()
                            ->placeholder('150000'),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Profil')
                    ->schema([
                        Forms\\Components\\FileUpload::make('photo')
                            ->label('Foto')
                            ->image()
                            ->directory('doctors')
                            ->maxSize(2048)
                            ->imageResizeMode('cover')
                            ->imageCropAspectRatio('1:1')
                            ->imageResizeTargetWidth('300')
                            ->imageResizeTargetHeight('300'),

                        Forms\\Components\\RichEditor::make('bio')
                            ->label('Biografi')
                            ->placeholder('Tuliskan latar belakang dan keahlian dokter...')
                            ->columnSpanFull(),

                        Forms\\Components\\Toggle::make('is_available')
                            ->label('Tersedia untuk Booking')
                            ->default(true)
                            ->helperText('Nonaktifkan jika dokter sedang cuti atau tidak praktik'),
                    ]),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\ImageColumn::make('photo')
                    ->label('Foto')
                    ->circular()
                    ->defaultImageUrl(asset('images/doctor-placeholder.png')),

                Tables\\Columns\\TextColumn::make('user.name')
                    ->label('Nama')
                    ->searchable()
                    ->sortable()
                    ->formatStateUsing(fn ($state) => 'dr. ' . $state),

                Tables\\Columns\\TextColumn::make('specialization.name')
                    ->label('Spesialisasi')
                    ->badge()
                    ->color('primary')
                    ->searchable(),

                Tables\\Columns\\TextColumn::make('license_number')
                    ->label('No. STR')
                    ->searchable()
                    ->toggleable(),

                Tables\\Columns\\TextColumn::make('consultation_fee')
                    ->label('Biaya')
                    ->money('IDR')
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('experience_years')
                    ->label('Pengalaman')
                    ->suffix(' tahun')
                    ->sortable(),

                Tables\\Columns\\IconColumn::make('is_available')
                    ->label('Tersedia')
                    ->boolean(),

                Tables\\Columns\\TextColumn::make('appointments_count')
                    ->label('Total Booking')
                    ->counts('appointments')
                    ->sortable(),
            ])
            ->filters([
                Tables\\Filters\\SelectFilter::make('specialization')
                    ->relationship('specialization', 'name')
                    ->label('Spesialisasi'),

                Tables\\Filters\\TernaryFilter::make('is_available')
                    ->label('Ketersediaan'),
            ])
            ->actions([
                Tables\\Actions\\ViewAction::make(),
                Tables\\Actions\\EditAction::make(),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make(),
                ]),
            ]);
    }

    public static function getRelations(): array
    {
        return [
            RelationManagers\\SchedulesRelationManager::class,
        ];
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\\ListDoctors::route('/'),
            'create' => Pages\\CreateDoctor::route('/create'),
            'view' => Pages\\ViewDoctor::route('/{record}'),
            'edit' => Pages\\EditDoctor::route('/{record}/edit'),
        ];
    }
}

Relation Manager: Schedules

Buat relation manager untuk jadwal dokter:

php artisan make:filament-relation-manager DoctorResource schedules day_of_week
<?php

// app/Filament/Resources/DoctorResource/RelationManagers/SchedulesRelationManager.php

namespace App\\Filament\\Resources\\DoctorResource\\RelationManagers;

use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\RelationManagers\\RelationManager;
use Filament\\Tables;
use Filament\\Tables\\Table;

class SchedulesRelationManager extends RelationManager
{
    protected static string $relationship = 'schedules';
    protected static ?string $title = 'Jadwal Praktik';

    public function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Select::make('day_of_week')
                    ->label('Hari')
                    ->options([
                        'monday'    => 'Senin',
                        'tuesday'   => 'Selasa',
                        'wednesday' => 'Rabu',
                        'thursday'  => 'Kamis',
                        'friday'    => 'Jumat',
                        'saturday'  => 'Sabtu',
                        'sunday'    => 'Minggu',
                    ])
                    ->required(),

                Forms\\Components\\TimePicker::make('start_time')
                    ->label('Jam Mulai')
                    ->required()
                    ->seconds(false),

                Forms\\Components\\TimePicker::make('end_time')
                    ->label('Jam Selesai')
                    ->required()
                    ->seconds(false)
                    ->after('start_time'),

                Forms\\Components\\TextInput::make('quota')
                    ->label('Kuota Pasien')
                    ->numeric()
                    ->default(10)
                    ->minValue(1)
                    ->maxValue(50)
                    ->required()
                    ->helperText('Maksimal pasien per slot waktu'),

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

    public function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('day_of_week')
                    ->label('Hari')
                    ->formatStateUsing(fn ($state) => match ($state) {
                        'monday'    => 'Senin',
                        'tuesday'   => 'Selasa',
                        'wednesday' => 'Rabu',
                        'thursday'  => 'Kamis',
                        'friday'    => 'Jumat',
                        'saturday'  => 'Sabtu',
                        'sunday'    => 'Minggu',
                    })
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('start_time')
                    ->label('Jam Mulai')
                    ->time('H:i'),

                Tables\\Columns\\TextColumn::make('end_time')
                    ->label('Jam Selesai')
                    ->time('H:i'),

                Tables\\Columns\\TextColumn::make('quota')
                    ->label('Kuota')
                    ->suffix(' pasien'),

                Tables\\Columns\\IconColumn::make('is_active')
                    ->label('Aktif')
                    ->boolean(),
            ])
            ->filters([
                //
            ])
            ->headerActions([
                Tables\\Actions\\CreateAction::make(),
            ])
            ->actions([
                Tables\\Actions\\EditAction::make(),
                Tables\\Actions\\DeleteAction::make(),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make(),
                ]),
            ]);
    }
}

Resource: AppointmentResource

php artisan make:filament-resource Appointment --generate
<?php

// app/Filament/Resources/AppointmentResource.php

namespace App\\Filament\\Resources;

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

class AppointmentResource extends Resource
{
    protected static ?string $model = Appointment::class;

    protected static ?string $navigationIcon = 'heroicon-o-calendar-days';
    protected static ?string $navigationLabel = 'Appointments';
    protected static ?string $navigationGroup = 'Transaksi';
    protected static ?int $navigationSort = 1;

    public static function getNavigationBadge(): ?string
    {
        return static::getModel()::pending()->count() ?: null;
    }

    public static function getNavigationBadgeColor(): ?string
    {
        return 'warning';
    }

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Informasi Booking')
                    ->schema([
                        Forms\\Components\\TextInput::make('booking_code')
                            ->label('Kode Booking')
                            ->disabled()
                            ->dehydrated(false)
                            ->visible(fn ($record) => $record !== null),

                        Forms\\Components\\Select::make('patient_id')
                            ->label('Pasien')
                            ->relationship('patient', 'name', fn (Builder $query) => $query->where('role', 'patient'))
                            ->searchable()
                            ->preload()
                            ->required(),

                        Forms\\Components\\Select::make('doctor_id')
                            ->label('Dokter')
                            ->relationship('doctor', 'id')
                            ->getOptionLabelFromRecordUsing(fn ($record) => 'dr. ' . $record->user->name . ' - ' . $record->specialization->name)
                            ->searchable()
                            ->preload()
                            ->required()
                            ->live(),

                        Forms\\Components\\Select::make('schedule_id')
                            ->label('Jadwal')
                            ->options(function (Forms\\Get $get) {
                                $doctorId = $get('doctor_id');
                                if (!$doctorId) return [];

                                return \\App\\Models\\Schedule::where('doctor_id', $doctorId)
                                    ->where('is_active', true)
                                    ->get()
                                    ->mapWithKeys(fn ($s) => [
                                        $s->id => "{$s->day_name} ({$s->time_range})"
                                    ]);
                            })
                            ->required()
                            ->visible(fn (Forms\\Get $get) => $get('doctor_id') !== null),

                        Forms\\Components\\DatePicker::make('appointment_date')
                            ->label('Tanggal')
                            ->required()
                            ->minDate(now())
                            ->native(false),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Detail Konsultasi')
                    ->schema([
                        Forms\\Components\\Textarea::make('complaint')
                            ->label('Keluhan Pasien')
                            ->required()
                            ->rows(3)
                            ->placeholder('Jelaskan keluhan atau gejala yang dialami...')
                            ->columnSpanFull(),

                        Forms\\Components\\Select::make('status')
                            ->label('Status')
                            ->options([
                                'pending'   => 'Menunggu Konfirmasi',
                                'confirmed' => 'Dikonfirmasi',
                                'completed' => 'Selesai',
                                'cancelled' => 'Dibatalkan',
                            ])
                            ->default('pending')
                            ->required(),

                        Forms\\Components\\Textarea::make('notes')
                            ->label('Catatan Dokter')
                            ->rows(3)
                            ->placeholder('Catatan setelah konsultasi...')
                            ->columnSpanFull(),
                    ]),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('booking_code')
                    ->label('Kode')
                    ->searchable()
                    ->copyable()
                    ->copyMessage('Kode booking disalin!')
                    ->weight('bold'),

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

                Tables\\Columns\\TextColumn::make('doctor.user.name')
                    ->label('Dokter')
                    ->formatStateUsing(fn ($state) => 'dr. ' . $state)
                    ->searchable(),

                Tables\\Columns\\TextColumn::make('doctor.specialization.name')
                    ->label('Spesialisasi')
                    ->badge()
                    ->toggleable(),

                Tables\\Columns\\TextColumn::make('appointment_date')
                    ->label('Tanggal')
                    ->date('d M Y')
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('schedule.time_range')
                    ->label('Jam'),

                Tables\\Columns\\BadgeColumn::make('status')
                    ->label('Status')
                    ->colors([
                        'warning' => 'pending',
                        'success' => 'confirmed',
                        'primary' => 'completed',
                        'danger'  => 'cancelled',
                    ])
                    ->formatStateUsing(fn ($state) => match ($state) {
                        'pending'   => 'Menunggu',
                        'confirmed' => 'Dikonfirmasi',
                        'completed' => 'Selesai',
                        'cancelled' => 'Dibatalkan',
                    }),

                Tables\\Columns\\TextColumn::make('created_at')
                    ->label('Dibuat')
                    ->dateTime('d M Y H:i')
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->defaultSort('appointment_date', 'desc')
            ->filters([
                Tables\\Filters\\SelectFilter::make('status')
                    ->options([
                        'pending'   => 'Menunggu',
                        'confirmed' => 'Dikonfirmasi',
                        'completed' => 'Selesai',
                        'cancelled' => 'Dibatalkan',
                    ]),

                Tables\\Filters\\SelectFilter::make('doctor')
                    ->relationship('doctor.user', 'name')
                    ->label('Dokter'),

                Tables\\Filters\\Filter::make('today')
                    ->label('Hari Ini')
                    ->query(fn (Builder $query) => $query->whereDate('appointment_date', today()))
                    ->toggle(),

                Tables\\Filters\\Filter::make('upcoming')
                    ->label('Akan Datang')
                    ->query(fn (Builder $query) => $query->whereDate('appointment_date', '>=', today()))
                    ->toggle(),
            ])
            ->actions([
                Tables\\Actions\\Action::make('confirm')
                    ->label('Konfirmasi')
                    ->icon('heroicon-o-check-circle')
                    ->color('success')
                    ->visible(fn ($record) => $record->status === 'pending')
                    ->requiresConfirmation()
                    ->action(fn ($record) => $record->confirm()),

                Tables\\Actions\\Action::make('complete')
                    ->label('Selesai')
                    ->icon('heroicon-o-clipboard-document-check')
                    ->color('primary')
                    ->visible(fn ($record) => $record->status === 'confirmed')
                    ->form([
                        Forms\\Components\\Textarea::make('notes')
                            ->label('Catatan Dokter')
                            ->rows(3),
                    ])
                    ->action(fn ($record, array $data) => $record->complete($data['notes'] ?? null)),

                Tables\\Actions\\EditAction::make(),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make(),
                ]),
            ]);
    }

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

Dashboard Widgets

Buat widget statistik untuk dashboard:

php artisan make:filament-widget StatsOverview --stats-overview
<?php

// app/Filament/Widgets/StatsOverview.php

namespace App\\Filament\\Widgets;

use App\\Models\\Appointment;
use App\\Models\\Doctor;
use App\\Models\\User;
use Filament\\Widgets\\StatsOverviewWidget as BaseWidget;
use Filament\\Widgets\\StatsOverviewWidget\\Stat;

class StatsOverview extends BaseWidget
{
    protected function getStats(): array
    {
        $todayAppointments = Appointment::today()->count();
        $pendingCount = Appointment::pending()->count();
        $thisMonthRevenue = Appointment::completed()
            ->whereMonth('appointment_date', now()->month)
            ->join('doctors', 'appointments.doctor_id', '=', 'doctors.id')
            ->sum('doctors.consultation_fee');

        return [
            Stat::make('Total Dokter', Doctor::available()->count())
                ->description('Dokter aktif')
                ->icon('heroicon-o-user-group')
                ->color('primary'),

            Stat::make('Total Pasien', User::patients()->count())
                ->description('Pasien terdaftar')
                ->icon('heroicon-o-users')
                ->color('success'),

            Stat::make('Appointment Hari Ini', $todayAppointments)
                ->description($pendingCount . ' menunggu konfirmasi')
                ->icon('heroicon-o-calendar')
                ->color('warning'),

            Stat::make('Pendapatan Bulan Ini', 'Rp ' . number_format($thisMonthRevenue, 0, ',', '.'))
                ->description('Dari konsultasi selesai')
                ->icon('heroicon-o-currency-dollar')
                ->color('success'),
        ];
    }
}
💡 TIPS FILAMENT 4:

1. Navigation Badge untuk notifikasi
   → getNavigationBadge() menampilkan jumlah pending
   → User langsung tahu ada yang perlu diproses

2. Relation Manager untuk nested data
   → Jadwal dokter di-manage langsung di halaman dokter
   → UX lebih baik, tidak perlu pindah halaman

3. Custom Actions untuk workflow
   → confirm(), complete() langsung dari table
   → Tidak perlu masuk ke edit form

4. Stats Widget untuk overview
   → Dashboard langsung informatif
   → Real-time data

Bagian 6: API Development dengan Laravel 12

Sekarang kita buat API yang akan diakses oleh frontend Vue. Kita akan pakai Sanctum untuk authentication.

Struktur API Routes

<?php

// routes/api.php

use App\\Http\\Controllers\\Api\\AppointmentController;
use App\\Http\\Controllers\\Api\\AuthController;
use App\\Http\\Controllers\\Api\\DoctorController;
use Illuminate\\Support\\Facades\\Route;

/*
|--------------------------------------------------------------------------
| Public Routes
|--------------------------------------------------------------------------
*/

// Authentication
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);

// Doctors (public)
Route::get('/doctors', [DoctorController::class, 'index']);
Route::get('/doctors/{doctor}', [DoctorController::class, 'show']);
Route::get('/specializations', [DoctorController::class, 'specializations']);

// Check booking status (public, dengan kode booking)
Route::get('/appointments/check/{bookingCode}', [AppointmentController::class, 'checkStatus']);

/*
|--------------------------------------------------------------------------
| Protected Routes (perlu login)
|--------------------------------------------------------------------------
*/

Route::middleware('auth:sanctum')->group(function () {
    // Auth
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::get('/user', [AuthController::class, 'user']);

    // Appointments
    Route::get('/appointments', [AppointmentController::class, 'index']);
    Route::post('/appointments', [AppointmentController::class, 'store']);
    Route::get('/appointments/{appointment}', [AppointmentController::class, 'show']);
    Route::put('/appointments/{appointment}/cancel', [AppointmentController::class, 'cancel']);
});

AuthController

php artisan make:controller Api/AuthController
<?php

// app/Http/Controllers/Api/AuthController.php

namespace App\\Http\\Controllers\\Api;

use App\\Http\\Controllers\\Controller;
use App\\Models\\User;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Auth;
use Illuminate\\Support\\Facades\\Hash;
use Illuminate\\Validation\\ValidationException;

class AuthController extends Controller
{
    /**
     * Register pasien baru
     */
    public function register(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'name'     => ['required', 'string', 'max:255'],
            'email'    => ['required', 'string', 'email', 'max:255', 'unique:users'],
            'password' => ['required', 'string', 'min:8', 'confirmed'],
            'phone'    => ['nullable', 'string', 'max:20'],
        ]);

        $user = User::create([
            'name'     => $validated['name'],
            'email'    => $validated['email'],
            'password' => Hash::make($validated['password']),
            'phone'    => $validated['phone'] ?? null,
            'role'     => 'patient', // Default sebagai pasien
        ]);

        $token = $user->createToken('auth_token')->plainTextToken;

        return response()->json([
            'success' => true,
            'message' => 'Registrasi berhasil',
            'data' => [
                'user' => [
                    'id'    => $user->id,
                    'name'  => $user->name,
                    'email' => $user->email,
                    'role'  => $user->role,
                ],
                'token' => $token,
            ],
        ], 201);
    }

    /**
     * Login user
     */
    public function login(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'email'    => ['required', 'string', 'email'],
            'password' => ['required', 'string'],
        ]);

        if (!Auth::attempt($validated)) {
            throw ValidationException::withMessages([
                'email' => ['Email atau password salah.'],
            ]);
        }

        $user = User::where('email', $validated['email'])->first();

        // Hapus token lama (opsional, untuk single session)
        // $user->tokens()->delete();

        $token = $user->createToken('auth_token')->plainTextToken;

        return response()->json([
            'success' => true,
            'message' => 'Login berhasil',
            'data' => [
                'user' => [
                    'id'    => $user->id,
                    'name'  => $user->name,
                    'email' => $user->email,
                    'role'  => $user->role,
                    'phone' => $user->phone,
                ],
                'token' => $token,
            ],
        ]);
    }

    /**
     * Logout user
     */
    public function logout(Request $request): JsonResponse
    {
        $request->user()->currentAccessToken()->delete();

        return response()->json([
            'success' => true,
            'message' => 'Logout berhasil',
        ]);
    }

    /**
     * Get current user
     */
    public function user(Request $request): JsonResponse
    {
        $user = $request->user();

        return response()->json([
            'success' => true,
            'data' => [
                'id'             => $user->id,
                'name'           => $user->name,
                'email'          => $user->email,
                'role'           => $user->role,
                'phone'          => $user->phone,
                'address'        => $user->address,
                'date_of_birth'  => $user->date_of_birth?->format('Y-m-d'),
                'gender'         => $user->gender,
            ],
        ]);
    }
}

DoctorController

php artisan make:controller Api/DoctorController
<?php

// app/Http/Controllers/Api/DoctorController.php

namespace App\\Http\\Controllers\\Api;

use App\\Http\\Controllers\\Controller;
use App\\Models\\Doctor;
use App\\Models\\Specialization;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\Http\\Request;

class DoctorController extends Controller
{
    /**
     * List semua dokter dengan filter
     */
    public function index(Request $request): JsonResponse
    {
        $query = Doctor::with(['user:id,name,email', 'specialization:id,name'])
            ->available()
            ->withCount('appointments');

        // Filter by specialization
        if ($request->filled('specialization_id')) {
            $query->bySpecialization($request->specialization_id);
        }

        // Search by name
        if ($request->filled('search')) {
            $search = $request->search;
            $query->whereHas('user', function ($q) use ($search) {
                $q->where('name', 'like', "%{$search}%");
            });
        }

        // Sorting
        $sortBy = $request->get('sort_by', 'experience_years');
        $sortOrder = $request->get('sort_order', 'desc');

        if ($sortBy === 'name') {
            $query->join('users', 'doctors.user_id', '=', 'users.id')
                  ->orderBy('users.name', $sortOrder)
                  ->select('doctors.*');
        } else {
            $query->orderBy($sortBy, $sortOrder);
        }

        $doctors = $query->paginate($request->get('per_page', 12));

        return response()->json([
            'success' => true,
            'data' => $doctors->map(function ($doctor) {
                return [
                    'id'               => $doctor->id,
                    'name'             => $doctor->user->name,
                    'email'            => $doctor->user->email,
                    'specialization'   => $doctor->specialization->name,
                    'specialization_id'=> $doctor->specialization_id,
                    'license_number'   => $doctor->license_number,
                    'experience_years' => $doctor->experience_years,
                    'consultation_fee' => $doctor->consultation_fee,
                    'formatted_fee'    => $doctor->formatted_fee,
                    'photo'            => $doctor->photo_url,
                    'is_available'     => $doctor->is_available,
                    'appointments_count' => $doctor->appointments_count,
                ];
            }),
            'meta' => [
                'current_page' => $doctors->currentPage(),
                'last_page'    => $doctors->lastPage(),
                'per_page'     => $doctors->perPage(),
                'total'        => $doctors->total(),
            ],
        ]);
    }

    /**
     * Detail dokter dengan jadwal
     */
    public function show(Doctor $doctor): JsonResponse
    {
        $doctor->load([
            'user:id,name,email,phone',
            'specialization:id,name,description',
            'schedules' => fn($q) => $q->where('is_active', true),
        ]);

        return response()->json([
            'success' => true,
            'data' => [
                'id'               => $doctor->id,
                'name'             => $doctor->user->name,
                'full_name'        => $doctor->full_name,
                'email'            => $doctor->user->email,
                'phone'            => $doctor->user->phone,
                'specialization'   => [
                    'id'          => $doctor->specialization->id,
                    'name'        => $doctor->specialization->name,
                    'description' => $doctor->specialization->description,
                ],
                'license_number'   => $doctor->license_number,
                'experience_years' => $doctor->experience_years,
                'consultation_fee' => $doctor->consultation_fee,
                'formatted_fee'    => $doctor->formatted_fee,
                'bio'              => $doctor->bio,
                'photo'            => $doctor->photo_url,
                'is_available'     => $doctor->is_available,
                'schedules'        => $doctor->schedules->map(fn($s) => [
                    'id'          => $s->id,
                    'day'         => $s->day_name,
                    'day_of_week' => $s->day_of_week,
                    'time_range'  => $s->time_range,
                    'start_time'  => $s->start_time,
                    'end_time'    => $s->end_time,
                    'quota'       => $s->quota,
                ]),
            ],
        ]);
    }

    /**
     * List spesialisasi
     */
    public function specializations(): JsonResponse
    {
        $specializations = Specialization::active()
            ->withCount(['doctors' => fn($q) => $q->available()])
            ->orderBy('name')
            ->get();

        return response()->json([
            'success' => true,
            'data' => $specializations->map(fn($s) => [
                'id'            => $s->id,
                'name'          => $s->name,
                'description'   => $s->description,
                'icon'          => $s->icon,
                'doctors_count' => $s->doctors_count,
            ]),
        ]);
    }
}

AppointmentController

php artisan make:controller Api/AppointmentController
<?php

// app/Http/Controllers/Api/AppointmentController.php

namespace App\\Http\\Controllers\\Api;

use App\\Http\\Controllers\\Controller;
use App\\Http\\Requests\\StoreAppointmentRequest;
use App\\Models\\Appointment;
use App\\Models\\Schedule;
use Carbon\\Carbon;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\Http\\Request;

class AppointmentController extends Controller
{
    /**
     * List appointments milik user yang login
     */
    public function index(Request $request): JsonResponse
    {
        $appointments = Appointment::with([
                'doctor.user:id,name',
                'doctor.specialization:id,name',
                'schedule',
            ])
            ->forPatient($request->user()->id)
            ->orderBy('appointment_date', 'desc')
            ->paginate(10);

        return response()->json([
            'success' => true,
            'data' => $appointments->map(fn($apt) => [
                'id'               => $apt->id,
                'booking_code'     => $apt->booking_code,
                'doctor'           => $apt->doctor->full_name,
                'specialization'   => $apt->doctor->specialization->name,
                'date'             => $apt->appointment_date->format('Y-m-d'),
                'formatted_date'   => $apt->formatted_date,
                'time'             => $apt->schedule->time_range,
                'complaint'        => $apt->complaint,
                'status'           => $apt->status,
                'status_label'     => $apt->status_label,
                'status_color'     => $apt->status_color,
                'notes'            => $apt->notes,
                'can_cancel'       => $apt->canBeCancelled(),
            ]),
            'meta' => [
                'current_page' => $appointments->currentPage(),
                'last_page'    => $appointments->lastPage(),
                'total'        => $appointments->total(),
            ],
        ]);
    }

    /**
     * Buat appointment baru
     */
    public function store(StoreAppointmentRequest $request): JsonResponse
    {
        $schedule = Schedule::findOrFail($request->schedule_id);

        // Validasi tanggal sesuai hari jadwal
        $appointmentDate = Carbon::parse($request->appointment_date);
        $dayOfWeek = strtolower($appointmentDate->englishDayOfWeek);

        if ($dayOfWeek !== $schedule->day_of_week) {
            return response()->json([
                'success' => false,
                'message' => "Tanggal harus jatuh pada hari {$schedule->day_name}",
            ], 422);
        }

        // Cek kuota
        if (!$schedule->isAvailableOn($request->appointment_date)) {
            return response()->json([
                'success' => false,
                'message' => 'Kuota untuk jadwal ini sudah penuh',
            ], 422);
        }

        // Cek duplikasi (pasien sudah booking di tanggal & jadwal yang sama)
        $existing = Appointment::where('patient_id', $request->user()->id)
            ->where('schedule_id', $schedule->id)
            ->where('appointment_date', $request->appointment_date)
            ->whereNotIn('status', ['cancelled'])
            ->exists();

        if ($existing) {
            return response()->json([
                'success' => false,
                'message' => 'Anda sudah memiliki booking di jadwal ini',
            ], 422);
        }

        $appointment = Appointment::create([
            'patient_id'       => $request->user()->id,
            'doctor_id'        => $schedule->doctor_id,
            'schedule_id'      => $schedule->id,
            'appointment_date' => $request->appointment_date,
            'complaint'        => $request->complaint,
            'status'           => 'pending',
        ]);

        $appointment->load(['doctor.user', 'doctor.specialization', 'schedule']);

        return response()->json([
            'success' => true,
            'message' => 'Booking berhasil dibuat',
            'data' => [
                'id'             => $appointment->id,
                'booking_code'   => $appointment->booking_code,
                'doctor'         => $appointment->doctor->full_name,
                'specialization' => $appointment->doctor->specialization->name,
                'date'           => $appointment->formatted_date,
                'time'           => $appointment->schedule->time_range,
                'status'         => $appointment->status,
                'status_label'   => $appointment->status_label,
            ],
        ], 201);
    }

    /**
     * Detail appointment
     */
    public function show(Appointment $appointment, Request $request): JsonResponse
    {
        // Pastikan milik user yang login
        if ($appointment->patient_id !== $request->user()->id) {
            return response()->json([
                'success' => false,
                'message' => 'Tidak memiliki akses',
            ], 403);
        }

        $appointment->load(['doctor.user', 'doctor.specialization', 'schedule']);

        return response()->json([
            'success' => true,
            'data' => [
                'id'             => $appointment->id,
                'booking_code'   => $appointment->booking_code,
                'doctor'         => $appointment->doctor->full_name,
                'specialization' => $appointment->doctor->specialization->name,
                'date'           => $appointment->appointment_date->format('Y-m-d'),
                'formatted_date' => $appointment->formatted_date,
                'time'           => $appointment->schedule->time_range,
                'complaint'      => $appointment->complaint,
                'status'         => $appointment->status,
                'status_label'   => $appointment->status_label,
                'notes'          => $appointment->notes,
                'can_cancel'     => $appointment->canBeCancelled(),
                'consultation_fee' => $appointment->doctor->formatted_fee,
            ],
        ]);
    }

    /**
     * Batalkan appointment
     */
    public function cancel(Appointment $appointment, Request $request): JsonResponse
    {
        if ($appointment->patient_id !== $request->user()->id) {
            return response()->json([
                'success' => false,
                'message' => 'Tidak memiliki akses',
            ], 403);
        }

        if (!$appointment->canBeCancelled()) {
            return response()->json([
                'success' => false,
                'message' => 'Appointment tidak dapat dibatalkan',
            ], 422);
        }

        $appointment->cancel();

        return response()->json([
            'success' => true,
            'message' => 'Appointment berhasil dibatalkan',
        ]);
    }

    /**
     * Cek status booking (public)
     */
    public function checkStatus(string $bookingCode): JsonResponse
    {
        $appointment = Appointment::with([
                'patient:id,name',
                'doctor.user:id,name',
                'doctor.specialization:id,name',
                'schedule',
            ])
            ->where('booking_code', $bookingCode)
            ->first();

        if (!$appointment) {
            return response()->json([
                'success' => false,
                'message' => 'Kode booking tidak ditemukan',
            ], 404);
        }

        return response()->json([
            'success' => true,
            'data' => [
                'booking_code'   => $appointment->booking_code,
                'patient_name'   => $appointment->patient->name,
                'doctor'         => $appointment->doctor->full_name,
                'specialization' => $appointment->doctor->specialization->name,
                'date'           => $appointment->formatted_date,
                'time'           => $appointment->schedule->time_range,
                'status'         => $appointment->status,
                'status_label'   => $appointment->status_label,
                'complaint'      => $appointment->complaint,
            ],
        ]);
    }
}

Form Request Validation

php artisan make:request StoreAppointmentRequest
<?php

// app/Http/Requests/StoreAppointmentRequest.php

namespace App\\Http\\Requests;

use Illuminate\\Foundation\\Http\\FormRequest;

class StoreAppointmentRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'schedule_id'      => ['required', 'exists:schedules,id'],
            'appointment_date' => ['required', 'date', 'after_or_equal:today'],
            'complaint'        => ['required', 'string', 'min:10', 'max:1000'],
        ];
    }

    public function messages(): array
    {
        return [
            'schedule_id.required'           => 'Jadwal harus dipilih',
            'schedule_id.exists'             => 'Jadwal tidak valid',
            'appointment_date.required'      => 'Tanggal harus diisi',
            'appointment_date.date'          => 'Format tanggal tidak valid',
            'appointment_date.after_or_equal'=> 'Tanggal minimal hari ini',
            'complaint.required'             => 'Keluhan harus diisi',
            'complaint.min'                  => 'Keluhan minimal 10 karakter',
            'complaint.max'                  => 'Keluhan maksimal 1000 karakter',
        ];
    }
}

Testing API dengan cURL

# Register
curl -X POST <http://hospital.test/api/register> \\
  -H "Content-Type: application/json" \\
  -d '{"name":"John Doe","email":"[email protected]","password":"password123","password_confirmation":"password123"}'

# Login
curl -X POST <http://hospital.test/api/login> \\
  -H "Content-Type: application/json" \\
  -d '{"email":"[email protected]","password":"password123"}'

# Get doctors
curl <http://hospital.test/api/doctors>

# Get doctor detail
curl <http://hospital.test/api/doctors/1>

# Create appointment (with token)
curl -X POST <http://hospital.test/api/appointments> \\
  -H "Content-Type: application/json" \\
  -H "Authorization: Bearer YOUR_TOKEN" \\
  -d '{"schedule_id":1,"appointment_date":"2026-03-15","complaint":"Sakit kepala sudah 3 hari"}'
💡 TIPS API DEVELOPMENT:

1. Konsisten dengan response format
   → Selalu ada success, message (optional), data
   → Frontend lebih mudah handle

2. Gunakan Form Request untuk validasi
   → Terpisah dari controller
   → Reusable dan testable

3. Eager load relasi yang dibutuhkan
   → Hindari N+1 query
   → with(['doctor.user', 'schedule'])

4. Return appropriate HTTP codes
   → 200 OK, 201 Created, 422 Validation Error
   → Frontend bisa handle error dengan benar

Di bagian selanjutnya, kita akan setup Vue 3 frontend dengan Composition API, Pinia store, dan komponen-komponen untuk booking dokter.


Bagian 7: Vue 3 Frontend Setup

Sekarang kita beralih ke frontend. Kita akan pakai Vue 3 dengan Composition API, TypeScript, dan Pinia untuk state management.

Setup Vue 3 Project

# Buat project Vue di folder terpisah
npm create vue@latest frontend

# Pilihan yang direkomendasikan:
# ✔ Add TypeScript? Yes
# ✔ Add JSX Support? No
# ✔ Add Vue Router? Yes
# ✔ Add Pinia? Yes
# ✔ Add Vitest? Yes
# ✔ Add ESLint? Yes
# ✔ Add Prettier? Yes

cd frontend
npm install

# Install dependencies tambahan
npm install axios
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Konfigurasi Tailwind

// tailwind.config.js

/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./index.html",
    "./src/**/*.{vue,js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {
      colors: {
        primary: {
          50: '#eff6ff',
          500: '#3b82f6',
          600: '#2563eb',
          700: '#1d4ed8',
        }
      }
    },
  },
  plugins: [],
}
/* src/assets/main.css */

@tailwind base;
@tailwind components;
@tailwind utilities;

Struktur Folder

frontend/src/
├── assets/
│   └── main.css
├── components/
│   ├── common/
│   │   ├── AppHeader.vue
│   │   ├── AppFooter.vue
│   │   └── LoadingSpinner.vue
│   ├── doctors/
│   │   ├── DoctorCard.vue
│   │   └── DoctorFilter.vue
│   └── booking/
│       ├── ScheduleSelector.vue
│       └── BookingForm.vue
├── composables/
│   ├── useApi.ts
│   └── useAuth.ts
├── stores/
│   └── auth.ts
├── views/
│   ├── HomeView.vue
│   ├── DoctorsView.vue
│   ├── DoctorDetailView.vue
│   ├── BookingView.vue
│   ├── MyAppointmentsView.vue
│   ├── LoginView.vue
│   └── RegisterView.vue
├── router/
│   └── index.ts
├── types/
│   └── index.ts
├── App.vue
└── main.ts

Type Definitions

// src/types/index.ts

export interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'doctor' | 'patient'
  phone?: string
}

export interface Specialization {
  id: number
  name: string
  description?: string
  icon?: string
  doctors_count: number
}

export interface Schedule {
  id: number
  day: string
  day_of_week: string
  time_range: string
  quota: number
}

export interface Doctor {
  id: number
  name: string
  full_name: string
  email: string
  specialization: string
  specialization_id: number
  license_number: string
  experience_years: number
  consultation_fee: number
  formatted_fee: string
  bio?: string
  photo: string
  is_available: boolean
  schedules?: Schedule[]
}

export interface Appointment {
  id: number
  booking_code: string
  doctor: string
  specialization: string
  date: string
  formatted_date: string
  time: string
  complaint: string
  status: 'pending' | 'confirmed' | 'completed' | 'cancelled'
  status_label: string
  status_color: string
  notes?: string
  can_cancel: boolean
}

export interface ApiResponse<T> {
  success: boolean
  message?: string
  data: T
  meta?: {
    current_page: number
    last_page: number
    total: number
  }
}

API Composable

// src/composables/useApi.ts

import axios from 'axios'
import type { AxiosInstance } from 'axios'
import { useAuthStore } from '@/stores/auth'
import type { Doctor, Specialization, Appointment, ApiResponse, User } from '@/types'

const api: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_URL || '<http://hospital.test/api>',
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
})

// Request interceptor - tambahkan token
api.interceptors.request.use((config) => {
  const authStore = useAuthStore()
  if (authStore.token) {
    config.headers.Authorization = `Bearer ${authStore.token}`
  }
  return config
})

// Response interceptor - handle 401
api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      const authStore = useAuthStore()
      authStore.logout()
    }
    return Promise.reject(error)
  }
)

export function useApi() {
  return {
    // Auth
    login: (email: string, password: string) =>
      api.post<ApiResponse<{ user: User; token: string }>>('/login', { email, password }),

    register: (data: { name: string; email: string; password: string; password_confirmation: string }) =>
      api.post<ApiResponse<{ user: User; token: string }>>('/register', data),

    logout: () => api.post('/logout'),

    getUser: () => api.get<ApiResponse<User>>('/user'),

    // Doctors
    getDoctors: (params?: Record<string, unknown>) =>
      api.get<ApiResponse<Doctor[]>>('/doctors', { params }),

    getDoctor: (id: number) =>
      api.get<ApiResponse<Doctor>>(`/doctors/${id}`),

    getSpecializations: () =>
      api.get<ApiResponse<Specialization[]>>('/specializations'),

    // Appointments
    createAppointment: (data: { schedule_id: number; appointment_date: string; complaint: string }) =>
      api.post<ApiResponse<Appointment>>('/appointments', data),

    getMyAppointments: (params?: Record<string, unknown>) =>
      api.get<ApiResponse<Appointment[]>>('/appointments', { params }),

    cancelAppointment: (id: number) =>
      api.put(`/appointments/${id}/cancel`),

    checkBookingStatus: (code: string) =>
      api.get<ApiResponse<Appointment>>(`/appointments/check/${code}`),
  }
}

Auth Store (Pinia)

// src/stores/auth.ts

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '@/composables/useApi'
import { useRouter } from 'vue-router'
import type { User } from '@/types'

export const useAuthStore = defineStore('auth', () => {
  const api = useApi()
  const router = useRouter()

  // State
  const user = ref<User | null>(null)
  const token = ref<string | null>(localStorage.getItem('token'))
  const loading = ref(false)

  // Getters
  const isAuthenticated = computed(() => !!token.value)
  const userName = computed(() => user.value?.name || '')

  // Actions
  async function login(email: string, password: string) {
    loading.value = true
    try {
      const response = await api.login(email, password)
      const { user: userData, token: authToken } = response.data.data

      user.value = userData
      token.value = authToken
      localStorage.setItem('token', authToken)

      router.push('/my-appointments')
      return { success: true }
    } catch (error: unknown) {
      const err = error as { response?: { data?: { message?: string } } }
      return {
        success: false,
        message: err.response?.data?.message || 'Login gagal',
      }
    } finally {
      loading.value = false
    }
  }

  async function register(data: {
    name: string
    email: string
    password: string
    password_confirmation: string
  }) {
    loading.value = true
    try {
      const response = await api.register(data)
      const { user: userData, token: authToken } = response.data.data

      user.value = userData
      token.value = authToken
      localStorage.setItem('token', authToken)

      router.push('/my-appointments')
      return { success: true }
    } catch (error: unknown) {
      const err = error as { response?: { data?: { message?: string; errors?: Record<string, string[]> } } }
      return {
        success: false,
        message: err.response?.data?.message || 'Registrasi gagal',
        errors: err.response?.data?.errors,
      }
    } finally {
      loading.value = false
    }
  }

  async function fetchUser() {
    if (!token.value) return
    try {
      const response = await api.getUser()
      user.value = response.data.data
    } catch {
      logout()
    }
  }

  function logout() {
    user.value = null
    token.value = null
    localStorage.removeItem('token')
    router.push('/')
  }

  return {
    user,
    token,
    loading,
    isAuthenticated,
    userName,
    login,
    register,
    fetchUser,
    logout,
  }
})

Router dengan Guards

// src/router/index.ts

import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      name: 'home',
      component: () => import('@/views/HomeView.vue'),
    },
    {
      path: '/doctors',
      name: 'doctors',
      component: () => import('@/views/DoctorsView.vue'),
    },
    {
      path: '/doctors/:id',
      name: 'doctor-detail',
      component: () => import('@/views/DoctorDetailView.vue'),
    },
    {
      path: '/booking/:doctorId',
      name: 'booking',
      component: () => import('@/views/BookingView.vue'),
      meta: { requiresAuth: true },
    },
    {
      path: '/my-appointments',
      name: 'my-appointments',
      component: () => import('@/views/MyAppointmentsView.vue'),
      meta: { requiresAuth: true },
    },
    {
      path: '/login',
      name: 'login',
      component: () => import('@/views/LoginView.vue'),
      meta: { guest: true },
    },
    {
      path: '/register',
      name: 'register',
      component: () => import('@/views/RegisterView.vue'),
      meta: { guest: true },
    },
    {
      path: '/check-booking',
      name: 'check-booking',
      component: () => import('@/views/CheckBookingView.vue'),
    },
  ],
})

// Navigation guard
router.beforeEach((to, _from, next) => {
  const authStore = useAuthStore()

  if (to.meta.requiresAuth && !authStore.isAuthenticated) {
    next({ name: 'login', query: { redirect: to.fullPath } })
  } else if (to.meta.guest && authStore.isAuthenticated) {
    next({ name: 'home' })
  } else {
    next()
  }
})

export default router

Bagian 8: Vue 3 Components

DoctorCard Component

<!-- src/components/doctors/DoctorCard.vue -->

<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import type { Doctor } from '@/types'

const props = defineProps<{
  doctor: Doctor
}>()

const photoUrl = computed(() => {
  return props.doctor.photo || '/images/doctor-placeholder.png'
})
</script>

<template>
  <div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow duration-200">
    <!-- Photo -->
    <div class="aspect-square bg-gray-100 overflow-hidden">
      <img
        :src="photoUrl"
        :alt="doctor.name"
        class="w-full h-full object-cover"
      />
    </div>

    <!-- Info -->
    <div class="p-4">
      <span class="inline-block px-2 py-1 text-xs font-medium text-primary-600 bg-primary-50 rounded-full mb-2">
        {{ doctor.specialization }}
      </span>

      <h3 class="font-semibold text-gray-900 mb-1">
        dr. {{ doctor.name }}
      </h3>

      <p class="text-sm text-gray-500 mb-3">
        {{ doctor.experience_years }} tahun pengalaman
      </p>

      <div class="flex items-center justify-between">
        <span class="text-sm font-medium text-gray-900">
          {{ doctor.formatted_fee }}
        </span>

        <RouterLink
          :to="{ name: 'doctor-detail', params: { id: doctor.id } }"
          class="text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors"
        >
          Lihat Profil →
        </RouterLink>
      </div>
    </div>
  </div>
</template>

BookingForm Component

<!-- src/components/booking/BookingForm.vue -->

<script setup lang="ts">
import { ref, computed } from 'vue'
import { useApi } from '@/composables/useApi'
import type { Schedule } from '@/types'

const props = defineProps<{
  doctorId: number
  schedules: Schedule[]
}>()

const emit = defineEmits<{
  success: [bookingCode: string]
}>()

const api = useApi()

// Form state
const selectedScheduleId = ref<number | null>(null)
const appointmentDate = ref('')
const complaint = ref('')
const loading = ref(false)
const error = ref('')

// Selected schedule
const selectedSchedule = computed(() => {
  if (!selectedScheduleId.value) return null
  return props.schedules.find(s => s.id === selectedScheduleId.value)
})

// Min date = today
const minDate = computed(() => {
  return new Date().toISOString().split('T')[0]
})

// Validate date matches schedule day
function validateDate(date: string): boolean {
  if (!selectedSchedule.value) return false

  const dayOfWeek = new Date(date)
    .toLocaleDateString('en-US', { weekday: 'long' })
    .toLowerCase()

  return dayOfWeek === selectedSchedule.value.day_of_week
}

// Submit
async function submitBooking() {
  error.value = ''

  // Validasi
  if (!selectedScheduleId.value) {
    error.value = 'Pilih jadwal terlebih dahulu'
    return
  }

  if (!appointmentDate.value) {
    error.value = 'Pilih tanggal appointment'
    return
  }

  if (!validateDate(appointmentDate.value)) {
    error.value = `Tanggal harus jatuh pada hari ${selectedSchedule.value?.day}`
    return
  }

  if (!complaint.value || complaint.value.length < 10) {
    error.value = 'Keluhan minimal 10 karakter'
    return
  }

  loading.value = true

  try {
    const response = await api.createAppointment({
      schedule_id: selectedScheduleId.value,
      appointment_date: appointmentDate.value,
      complaint: complaint.value,
    })

    emit('success', response.data.data.booking_code)
  } catch (err: unknown) {
    const apiError = err as { response?: { data?: { message?: string } } }
    error.value = apiError.response?.data?.message || 'Terjadi kesalahan'
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <form @submit.prevent="submitBooking" class="space-y-6">
    <!-- Error -->
    <div v-if="error" class="p-4 bg-red-50 text-red-700 rounded-lg text-sm">
      {{ error }}
    </div>

    <!-- Schedule selector -->
    <div>
      <label class="block text-sm font-medium text-gray-700 mb-2">
        Pilih Jadwal
      </label>
      <div class="grid grid-cols-2 gap-2">
        <button
          v-for="schedule in schedules"
          :key="schedule.id"
          type="button"
          @click="selectedScheduleId = schedule.id"
          :class="[
            'p-3 text-sm rounded-lg border transition-colors text-left',
            selectedScheduleId === schedule.id
              ? 'border-primary-500 bg-primary-50 text-primary-700'
              : 'border-gray-200 hover:border-gray-300'
          ]"
        >
          <div class="font-medium">{{ schedule.day }}</div>
          <div class="text-gray-500">{{ schedule.time_range }}</div>
        </button>
      </div>
    </div>

    <!-- Date picker -->
    <div>
      <label class="block text-sm font-medium text-gray-700 mb-1">
        Tanggal Appointment
      </label>
      <input
        type="date"
        v-model="appointmentDate"
        :min="minDate"
        class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
      />
      <p v-if="selectedSchedule" class="mt-1 text-sm text-gray-500">
        Pilih tanggal yang jatuh pada hari {{ selectedSchedule.day }}
      </p>
    </div>

    <!-- Complaint -->
    <div>
      <label class="block text-sm font-medium text-gray-700 mb-1">
        Keluhan
      </label>
      <textarea
        v-model="complaint"
        rows="4"
        placeholder="Jelaskan keluhan atau gejala yang dialami..."
        class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
      ></textarea>
    </div>

    <!-- Submit -->
    <button
      type="submit"
      :disabled="loading"
      class="w-full py-3 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
    >
      {{ loading ? 'Memproses...' : 'Buat Appointment' }}
    </button>
  </form>
</template>

DoctorsView Page

<!-- src/views/DoctorsView.vue -->

<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useApi } from '@/composables/useApi'
import DoctorCard from '@/components/doctors/DoctorCard.vue'
import type { Doctor, Specialization } from '@/types'

const api = useApi()

// State
const doctors = ref<Doctor[]>([])
const specializations = ref<Specialization[]>([])
const loading = ref(true)
const searchQuery = ref('')
const selectedSpecialization = ref<number | null>(null)
const currentPage = ref(1)
const totalPages = ref(1)

// Fetch doctors
async function fetchDoctors() {
  loading.value = true
  try {
    const response = await api.getDoctors({
      search: searchQuery.value || undefined,
      specialization_id: selectedSpecialization.value || undefined,
      page: currentPage.value,
    })
    doctors.value = response.data.data
    totalPages.value = response.data.meta?.last_page || 1
  } catch (error) {
    console.error('Error:', error)
  } finally {
    loading.value = false
  }
}

// Fetch specializations
async function fetchSpecializations() {
  try {
    const response = await api.getSpecializations()
    specializations.value = response.data.data
  } catch (error) {
    console.error('Error:', error)
  }
}

// Watch filters
watch([searchQuery, selectedSpecialization], () => {
  currentPage.value = 1
  fetchDoctors()
})

onMounted(() => {
  fetchDoctors()
  fetchSpecializations()
})
</script>

<template>
  <div class="min-h-screen bg-gray-50">
    <div class="container mx-auto px-4 py-8">
      <h1 class="text-2xl font-bold text-gray-900 mb-6">Daftar Dokter</h1>

      <!-- Filters -->
      <div class="flex flex-col sm:flex-row gap-4 mb-8">
        <input
          v-model="searchQuery"
          type="text"
          placeholder="Cari nama dokter..."
          class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
        />

        <select
          v-model="selectedSpecialization"
          class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
        >
          <option :value="null">Semua Spesialisasi</option>
          <option
            v-for="spec in specializations"
            :key="spec.id"
            :value="spec.id"
          >
            {{ spec.name }} ({{ spec.doctors_count }})
          </option>
        </select>
      </div>

      <!-- Loading -->
      <div v-if="loading" class="flex justify-center py-12">
        <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
      </div>

      <!-- Doctors grid -->
      <div v-else-if="doctors.length > 0" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
        <DoctorCard
          v-for="doctor in doctors"
          :key="doctor.id"
          :doctor="doctor"
        />
      </div>

      <!-- Empty -->
      <div v-else class="text-center py-12">
        <p class="text-gray-500">Tidak ada dokter ditemukan.</p>
      </div>

      <!-- Pagination -->
      <div v-if="totalPages > 1" class="flex justify-center gap-2 mt-8">
        <button
          v-for="page in totalPages"
          :key="page"
          @click="currentPage = page; fetchDoctors()"
          :class="[
            'px-4 py-2 rounded-lg transition-colors',
            currentPage === page
              ? 'bg-primary-600 text-white'
              : 'bg-white text-gray-700 hover:bg-gray-100'
          ]"
        >
          {{ page }}
        </button>
      </div>
    </div>
  </div>
</template>
💡 TIPS VUE 3:

1. Pakai <script setup> untuk boilerplate minimal
   → Tidak perlu export default, return, dll

2. Composables untuk logic reusable
   → useApi() bisa dipakai di semua component

3. Pinia store hanya untuk global state
   → Auth, cart, settings
   → Local state tetap pakai ref/reactive

4. TypeScript untuk type safety
   → Autocomplete lebih baik
   → Error ketahuan saat development

Bagian 9: Testing & Deployment

Feature Testing Laravel

<?php

// tests/Feature/AppointmentTest.php

namespace Tests\\Feature;

use App\\Models\\Appointment;
use App\\Models\\Doctor;
use App\\Models\\Schedule;
use App\\Models\\Specialization;
use App\\Models\\User;
use Illuminate\\Foundation\\Testing\\RefreshDatabase;
use Tests\\TestCase;

class AppointmentTest extends TestCase
{
    use RefreshDatabase;

    private User $patient;
    private Doctor $doctor;
    private Schedule $schedule;

    protected function setUp(): void
    {
        parent::setUp();

        // Setup data
        $this->patient = User::factory()->create(['role' => 'patient']);

        $specialization = Specialization::create([
            'name' => 'Umum',
            'is_active' => true,
        ]);

        $doctorUser = User::factory()->create(['role' => 'doctor']);

        $this->doctor = Doctor::create([
            'user_id' => $doctorUser->id,
            'specialization_id' => $specialization->id,
            'license_number' => 'STR123456',
            'consultation_fee' => 150000,
            'is_available' => true,
        ]);

        $this->schedule = Schedule::create([
            'doctor_id' => $this->doctor->id,
            'day_of_week' => 'monday',
            'start_time' => '09:00',
            'end_time' => '12:00',
            'quota' => 10,
            'is_active' => true,
        ]);
    }

    public function test_patient_can_create_appointment(): void
    {
        // Cari tanggal Senin terdekat
        $monday = now()->next('Monday')->format('Y-m-d');

        $response = $this->actingAs($this->patient)
            ->postJson('/api/appointments', [
                'schedule_id' => $this->schedule->id,
                'appointment_date' => $monday,
                'complaint' => 'Sakit kepala sudah 3 hari tidak sembuh',
            ]);

        $response->assertStatus(201)
            ->assertJsonStructure([
                'success',
                'message',
                'data' => ['booking_code', 'doctor', 'date', 'time', 'status'],
            ]);

        $this->assertDatabaseHas('appointments', [
            'patient_id' => $this->patient->id,
            'doctor_id' => $this->doctor->id,
            'status' => 'pending',
        ]);
    }

    public function test_appointment_validates_date_matches_schedule(): void
    {
        // Coba booking di hari Selasa (schedule hanya Senin)
        $tuesday = now()->next('Tuesday')->format('Y-m-d');

        $response = $this->actingAs($this->patient)
            ->postJson('/api/appointments', [
                'schedule_id' => $this->schedule->id,
                'appointment_date' => $tuesday,
                'complaint' => 'Sakit kepala sudah 3 hari',
            ]);

        $response->assertStatus(422)
            ->assertJson(['success' => false]);
    }

    public function test_appointment_respects_quota(): void
    {
        // Buat schedule dengan quota 1
        $limitedSchedule = Schedule::create([
            'doctor_id' => $this->doctor->id,
            'day_of_week' => 'tuesday',
            'start_time' => '13:00',
            'end_time' => '16:00',
            'quota' => 1,
            'is_active' => true,
        ]);

        $tuesday = now()->next('Tuesday')->format('Y-m-d');

        // Booking pertama berhasil
        Appointment::create([
            'patient_id' => User::factory()->create(['role' => 'patient'])->id,
            'doctor_id' => $this->doctor->id,
            'schedule_id' => $limitedSchedule->id,
            'appointment_date' => $tuesday,
            'complaint' => 'Test complaint',
            'status' => 'pending',
        ]);

        // Booking kedua harus gagal
        $response = $this->actingAs($this->patient)
            ->postJson('/api/appointments', [
                'schedule_id' => $limitedSchedule->id,
                'appointment_date' => $tuesday,
                'complaint' => 'Sakit kepala sudah 3 hari',
            ]);

        $response->assertStatus(422)
            ->assertJson([
                'success' => false,
                'message' => 'Kuota untuk jadwal ini sudah penuh',
            ]);
    }

    public function test_patient_can_cancel_appointment(): void
    {
        $monday = now()->next('Monday')->format('Y-m-d');

        $appointment = Appointment::create([
            'patient_id' => $this->patient->id,
            'doctor_id' => $this->doctor->id,
            'schedule_id' => $this->schedule->id,
            'appointment_date' => $monday,
            'complaint' => 'Test',
            'status' => 'pending',
        ]);

        $response = $this->actingAs($this->patient)
            ->putJson("/api/appointments/{$appointment->id}/cancel");

        $response->assertOk()
            ->assertJson(['success' => true]);

        $this->assertDatabaseHas('appointments', [
            'id' => $appointment->id,
            'status' => 'cancelled',
        ]);
    }
}

Menjalankan Tests

php artisan test

# Output:
# PASS  Tests\\Feature\\AppointmentTest
# ✓ patient can create appointment
# ✓ appointment validates date matches schedule
# ✓ appointment respects quota
# ✓ patient can cancel appointment

Deployment Checklist

PRODUCTION CHECKLIST:

┌─────────────────────────────────────────────────────────┐
│                    LARAVEL BACKEND                       │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  Environment:                                           │
│  □ APP_ENV=production                                   │
│  □ APP_DEBUG=false                                      │
│  □ APP_KEY generated                                    │
│  □ Database configured                                  │
│                                                         │
│  Optimization:                                          │
│  □ php artisan config:cache                            │
│  □ php artisan route:cache                             │
│  □ php artisan view:cache                              │
│  □ php artisan optimize                                │
│                                                         │
│  Database:                                              │
│  □ php artisan migrate --force                         │
│  □ php artisan db:seed (if needed)                     │
│                                                         │
│  Security:                                              │
│  □ CORS configured                                      │
│  □ Rate limiting enabled                               │
│  □ HTTPS only                                          │
│                                                         │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                    VUE FRONTEND                          │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  Build:                                                 │
│  □ npm run build                                        │
│  □ VITE_API_URL configured                             │
│                                                         │
│  Deploy:                                                │
│  □ Upload dist/ folder                                 │
│  □ Configure SPA routing                               │
│                                                         │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                      SERVER                              │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  Requirements:                                          │
│  □ PHP 8.2+ with extensions                            │
│  □ Composer                                             │
│  □ MySQL 8.x                                           │
│  □ Nginx/Apache                                        │
│  □ SSL certificate                                      │
│                                                         │
│  Services:                                              │
│  □ Queue worker (Supervisor)                           │
│  □ Scheduler (Cron)                                    │
│  □ Redis (optional, for cache)                         │
│                                                         │
└─────────────────────────────────────────────────────────┘

Bagian 10: Rekomendasi Belajar di BuildWithAngga

Kita sudah menyelesaikan project yang cukup komprehensif. Mari recap apa yang sudah dipelajari:

Yang Sudah Dipelajari

✅ Laravel 12
   ├── Project setup dan konfigurasi
   ├── Migrations dan database design
   ├── Eloquent Models dengan relationships
   ├── API development dengan Sanctum
   └── Form validation

✅ Filament 4
   ├── Admin panel setup
   ├── Resources dengan CRUD
   ├── Relation managers
   ├── Custom actions
   └── Dashboard widgets

✅ Vue 3
   ├── Composition API
   ├── TypeScript integration
   ├── Pinia state management
   ├── Vue Router dengan guards
   └── Reusable components

✅ Best Practices
   ├── Code organization
   ├── Testing
   └── Deployment

Kelas Gratis di BuildWithAngga

Untuk memperkuat fundamental, ambil kelas gratis ini:

📚 KELAS GRATIS BWA:

1. Laravel Fundamental
   └── Routing, Controller, Eloquent basics
   └── buildwithangga.com/kelas/laravel

2. Vue.js Fundamental
   └── Composition API, Components
   └── buildwithangga.com/kelas/vue-js

3. SQL for Beginners
   └── Query, JOIN, Database design
   └── buildwithangga.com/kelas/sql-for-beginners

4. Tailwind CSS
   └── Utility-first styling
   └── buildwithangga.com/kelas/tailwind-css

Kelas Premium BuildWithAngga

Untuk level up ke production-ready:

KelasYang Dipelajari
Full-Stack Laravel + VueE-commerce lengkap, Payment, Deploy
Laravel Filament CompleteMulti-tenancy, Roles, Advanced features
Laravel API MasterclassVersioning, Documentation, Security
Vue 3 AdvancedNuxt, SSR, Performance optimization

Benefit Kelas Premium

┌─────────────────────────────────────────────────────────┐
│           BENEFIT KELAS PREMIUM BWA                     │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  📦 PROJECT PORTFOLIO-READY                             │
│     Dari setup sampai deployment                        │
│     Source code lengkap                                 │
│     Bisa langsung dipakai atau dijual                  │
│                                                         │
│  ♾️ AKSES SEUMUR HIDUP                                  │
│     Beli sekali, akses forever                         │
│     Update materi gratis                               │
│     Termasuk materi tambahan                           │
│                                                         │
│  👨‍🏫 KONSULTASI MENTOR                                  │
│     Tanya langsung via forum                           │
│     Code review dari praktisi                          │
│     Career guidance                                    │
│                                                         │
│  📜 SERTIFIKAT RESMI                                    │
│     Bukti kompetensi                                   │
│     LinkedIn-ready                                     │
│     Nilai plus untuk CV                                │
│                                                         │
│  👥 KOMUNITAS 900.000+ STUDENTS                        │
│     Networking                                          │
│     Info lowongan & freelance                          │
│     Sharing pengalaman                                 │
│                                                         │
└─────────────────────────────────────────────────────────┘

Penutup

Stack Laravel + Vue + Filament bukan sekadar trend. Ini adalah kombinasi yang sudah terbukti menghasilkan aplikasi profesional dengan developer experience yang luar biasa.

Di tutorial ini, kita sudah membangun sistem booking dokter yang:

  • Punya admin panel lengkap
  • API yang secure
  • Frontend yang responsive
  • Code yang maintainable

Tapi ini baru fondasi. Dari sini, kamu bisa kembangkan lebih jauh: payment integration, notifications, teleconsultation, mobile app, dan banyak lagi.

Yang membedakan developer biasa dengan developer yang dibayar mahal adalah: konsistensi belajar dan portfolio nyata.

Mulai dari project seperti ini. Kembangkan. Deploy. Tunjukkan ke dunia.

Sampai jumpa di kelas BuildWithAngga.


Angga Risky Founder, BuildWithAngga

👉 buildwithangga.com