Membangun Sistem SaaS Laundry Management dengan Laravel 12, Filament, Spatie, dan Midtrans

Halo people with the spirit of learning,. Kali ini kita akan membangun sesuatu yang sangat menarik dan punya potensi bisnis yang luar biasa - sistem SaaS (Software as a Service) untuk manajemen laundry menggunakan teknologi modern seperti Laravel 12, Filament, Spatie packages, dan integrasi pembayaran Midtrans. Proyek ini bukan cuma sekedar aplikasi biasa, tapi sistem yang benar-benar bisa digunakan untuk mengelola bisnis laundry secara profesional dengan pendekatan multi-tenant SaaS.

Mengapa SaaS Laundry Management System?

Bisnis laundry di Indonesia sedang berkembang pesat, terutama di area urban dan semi-urban. Banyak pengusaha yang mulai membuka cabang laundry kedua, ketiga, bahkan puluhan cabang. Nah, di sinilah masalahnya - mengelola multiple cabang dengan sistem manual atau aplikasi desktop tradisional itu ribet banget dan tidak efisien. Bayangkan kalau kamu punya 10 cabang laundry, terus harus bolak-balik ke setiap cabang cuma buat cek laporan harian atau monitoring performa karyawan. Pasti capek dan memakan waktu yang sangat banyak.

Di sinilah konsep SaaS menjadi solusi yang brilliant. Dengan model SaaS, pemilik bisnis laundry bisa mengakses dashboard management dari mana aja, kapan aja, cukup dengan koneksi internet. Mereka bisa monitoring semua cabang dalam satu platform, melihat real-time analytics, mengelola inventory, mengatur shift karyawan, sampai generate laporan keuangan komprehensif. Yang lebih keren lagi, dengan model subscription-based, kita sebagai developer bisa mendapatkan recurring revenue yang sustainable.

Keunggulan Model SaaS untuk Bisnis Laundry Modern

Model SaaS itu perfect banget untuk bisnis laundry karena beberapa alasan strategis. Pertama, bisnis laundry itu nature-nya sudah subscription-based juga. Pelanggan laundry biasanya punya pattern yang rutin - misalnya seminggu sekali atau dua minggu sekali. Dengan sistem SaaS, kita bisa integrate fitur membership dan loyaalty program yang otomatis, sehingga customer retention meningkat drastis.

Kedua, scalability. Bisnis laundry yang successful pasti akan expand ke multiple locations. Dengan arsitektur SaaS yang kita bangun nanti, adding new branch itu semudah beberapa klik aja. Database structure kita design dengan multi-tenant approach, jadi setiap cabang punya data yang isolated tapi masih bisa di-aggregate untuk reporting level corporate.

Ketiga, operational efficiency. Dengan dashboard admin yang sophisticated, owner bisa melakukan micro-management yang efektif. Misalnya, mereka bisa set automatic pricing berdasarkan jenis pakaian, berat, dan service level. Mereka juga bisa track performa individual staff, monitoring machine utilization, bahgkan predict peak hours berdasarkan historical data.

Teknologi Stack yang Akan Kita Gunakan

Untuk backend, kita menggunakan Laravel 12 yang merupakan versi terbaru dengan performance improvements yang signifikan dan fitur-fitur modern yang sangat membantu development process. Laravel 12 dilengkapi dengan enhanced queue system, better database optimization, dan security improvements yang crucial untuk aplikasi SaaS.

Filament akan menjadi core dari admin dashboard kita. Jujur aja, Filament itu game-changer banget untuk rapid development admin panel yang professional-grade. Dengan Filament, kita bisa create sophisticated CRUD operations, complex filtering, advanced search functionality, custom widgets untuk analytics, sampai export/import features - semua itu dengan minimal coding effort. UI/UX yang dihasilkan Filament juga sangat modern dan responsive, jadi client bakal impressed dengan hasil akhirnya.

Spatie packages akan kita leverage untuk berbagai functionality yang kompleks. Spatie/permission untuk role-based access control yang granular, spatie/media-library untuk efficient file management (karena kita perlu handle customer photos, receipt images, etc.), spatie/backup untuk automated database backup, dan spatie/activitylog untuk comprehensive audit trails. Spatie ecosystem itu mature bangeet dan battle-tested, jadi kita gak perlu reinvent the wheel.

Midtrans integration akan handle semua payment processing, baik untuk customer payments maupun subscription fees dari laundry owners. Midtrans support various payment methods yang populer di Indonesia - credit cards, bank transfers, e-wallets, sampai convenience store payments. Yang penting, Midtrans punya robust API documentation dan excellent sandbox environment untuk testing.

Fitur Langganan SaaS yang Powerful

Subscription management adalah differentiator utama aplikasi kita dengan competitors. Kita akan implement multi-tier pricing plans - Basic plan untuk single branch dengan limited features, Professional plan untuk multiple branches dengan advanced analytics, dan Enterprise plan dengan white-label options dan custom integrations.

Payment processing untuk subscriptions akan fully automated menggunakan Midtrans recurring payment features. Sistem akan automatically charge monthly/yearly fees, handle failed payments dengan retry mechanisms, send payment remindeers, dan graceful service degradation untuk overdue accounts.

User management akan support multiple admin levels within each laundry business - Super Admin (owner level), Branch Managers, Staff, dan Accountants - masing-masing dengan permission sets yang berbeda. Multi-tenant architecture ensure complete data isolation antar customers sambil maintaining efficiency dalam resource utilization.

Preparation untuk Development Journey

Sebelum kita mulai coding di artikel-artikel berikutnaya, penting untuk understand bahwa kita akan build aplikasi ini step-by-step dengan approach yang structured dan maintainable. Kita akan start dengan solid foundation - database design, authentication system, dan basic CRUD operations. Kemudian gradually add complex features seperti reporting, payment integration, dan advanced SaaS functionalities.

Code quality akan jadi priority utama. Kita aaakan implement proper testing strategies, follow Laravel best practices, maintain clean architecture dengan separation of concerns, dan ensure scalability dari awal development process. Documentation juga akan comprehensive supaya aplikasi ini bisa di-maintain dan di-extend dengan mudah.

Security considerations akan integrate dari beginning, karena aplikasi SaaS handle sensitive business data dan payment inforamation. Kita akan implement proper data encryption, secure API endpoints, protection against common vulnerabilities, dan compliance dengan data protection regulations.

Persiapan Environment Development

Sebelum kita mulai, pastikan environment development kamu sudah ready dengan requirements yang diperlukan. Kamu perlu PHP versi 8.2 atau lebih tinggi karena Laravel 12 membutuhkan PHP modern untuk optimal performance. Composer juga harus sudah terinstall sebagai dependency manager utama untuk PHP ecosystem.

MySQL atau MariaDB harus sudah running di local machine atau server development kamu. Kalau kamu menggunakan XAMPP, WAMP, atau MAMP, pastikan MySQL service sudah active. Untuk yang prefer menggunakan Laravel Sail atau Docker, itu juga fine - tapi di tutorial ini kita akan fokus ke traditional setup yang lebih straightforward.

Node.js dan NPM/Yarn juga akan diperlukan nanti untuk asset compilation, tapi untuk sekarang kita fokus ke backend setup dulu. Git juga highly recommended untuk version control, terutama karena kita akan build aplikasi yang complex dengan multiple features.

Membuat Project Laravel Baru

Mari kita mulai dengan membuat project Laravel baru menggunakan Composer. Command yang kita gunakan adalah composer create-project yang akan download Laravel skeleton dan setup initial structure untuk kita.

composer create-project laravel/laravel saas-laundry-management

Command ini akan membuat folder baru bernama "saas-laundry-management" dan download semua dependencies yang diperlukan. Process ini biasanya butuh beberapa menit tergantung speed internet connection kamu. Composer akan automatically resolve all package dependencies dan setup autoloading configuration.

Setelah process selesai, masuk ke directory project yang baru dibuat:

cd saas-laundry-management

Sekarang kita bisa explore structure project Laravel yang fresh. Kamu akan melihat folder-folder familiar seperti app/, config/, database/, routes/, dan resources/. Structure ini adalah standard Laravel convention yang akan memudahkan development dan maintenance ke depannya.

Konfigurasi Environment Variables

Langkah selanjutnya adalah setup environment configuration melalui file .env. File ini adalah heart dari Laravel configuration dimana kita define berbagai settings seperti database connection, mail configuration, cache drivers, dan environment-specific variables lainnya.

Pertama, copy file .env.example menjadi .env:

cp .env.example .env

Atau kalau kamu di Windows dan tidak menggunakan Git Bash:

copy .env.example .env

Sekarang buka file .env dengan text editor favorit kamu. File ini berisi berbagai configuration variables yang perlu kita adjust sesuai environment development kita.

Setup Database Configuration

Bagian paling crucial dari .env configuration adalah database setup. Mari kita configure MySQL connection dengan detail yang sesuai setup local kamu:

APP_NAME="SaaS Laundry Management"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000

LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug

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

BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120

Mari kita breakdown setiap configuration variable yang important:

DB_CONNECTION=mysql menentukan bahwa kita menggunakan MySQL sebagai database driver. Laravel support berbagai database engines seperti PostgreSQL, SQLite, dan SQL Server, tapi untuk project ini MySQL adalah pilihan yang optimal.

DB_HOST=127.0.0.1 adalah IP address localhost dimana MySQL server running. Kalau kamu menggunakan XAMPP atau similar local development environment, value ini biasanya sudah benar. Untuk production nanti, ini akan diganti dengan actual server IP atau domain.

DB_PORT=3306 adalah default port untuk MySQL. Kebanyakan installation menggunakan port ini, tapi kalau kamu custom installation dengan port berbeda, adjust sesuai configuration kamu.

DB_DATABASE=saas_laundry_management adalah nama database yang akan kita gunakan. Pastikan database dengan nama ini sudah exists di MySQL server kamu, atau kita akan create manually sebentar lagi.

DB_USERNAME=root adalah username untuk database connection. Untuk local development, biasanya "root" sudah cukup. Di production environment, strongly recommended untuk create dedicated database user dengan limited privileges sesuai principle of least privilege.

DB_PASSWORD= di set kosong karena default XAMPP installation biasanya tidak ada password untuk root user. Kalau MySQL installation kamu ada password, isi sesuai credential yang benar.

Generate Application Key

Laravel membutuhkan unique application key untuk encryption dan security purposes. Generate key ini dengan command:

php artisan key:generate

Command ini akan automatically update APP_KEY variable di file .env dengan randomly generated secure key. Key ini digunakan untuk encrypting cookies, session data, dan data sensitive lainnya.

Membuat Database

Sebelum kita test connection, kita perlu create database yang sudah kita specify di .env file. Ada beberapa cara untuk melakukan ini:

Melalui MySQL command line:

CREATE DATABASE saas_laundry_management CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

Character set utf8mb4 dengan collation utf8mb4_unicode_ci adalah recommended setting untuk support full Unicode characters termasuk emoji dan special characters yang mungkin diperlukan di aplikasi kita.

Atau kalau kamu prefer menggunakan phpMyAdmin (biasanya available di http://localhost/phpmyadmin kalau pakai XAMPP), kamu bisa create database melalui web interface dengan settings yang sama.

Untuk yang menggunakan MySQL Workbench atau database management tools lainnya, process-nya similar - create new schema dengan nama "saas_laundry_management" dan character set utf8mb4.

Testing Database Connection

Sekarang mari kita test apakah database connection sudah properly configured. Laravel menyediakan built-in command untuk testing database connectivity:

php artisan migrate:status

Kalau connection berhasil, kamu akan melihat output yang menunjukkan migration status (walaupun belum ada migrations yang di-run). Kalau ada error, biasanya terkait dengan:

Database credential yang salah di file .env - double-check username, password, dan database name. Database server yang belum running - pastikan MySQL service active. Database yang belum exists - create database sesuai nama yang di-specify di DB_DATABASE. Firewall atau permission issues - pastikan PHP bisa connect ke MySQL port.

Menjalankan Default Migrations

Laravel ships dengan beberapa default migrations untuk user authentication, password resets, dan failed jobs handling. Mari kita run migrations ini untuk setup basic table structure:

php artisan migrate

Command ini akan create several tables di database kita:

users table untuk menyimpan user data dengan columns seperti name, email, password, dan timestamps. Table ini akan kita extend nanti dengan additional fields yang specific untuk laundry management system.

password_reset_tokens table untuk handling password reset functionality yang secure dan time-limited.

failed_jobs table untuk logging failed queue jobs yang akan berguna untuk debugging dan monitoring system performance.

personal_access_tokens table kalau kamu menggunakan Laravel Sanctum untuk API authentication (yang kemungkinan besar akan kita implement untuk mobile app integration).

Kalau migration berhasil, kamu akan melihat output yang confirmative menunjukkan semua migrations telah di-execute successfully.

Verifikasi Installation

Untuk memastikan everything works properly, mari kita start development server dan access aplikasi di browser:

php artisan serve

Command ini akan start built-in PHP development server di http://localhost:8000. Buka URL ini di browser, dan kamu should melihat Laravel welcome page yang menunjukkan bahwa installation berhasil.

Welcome page juga akan show beberapa useful information seperti Laravel version, PHP version, dan links ke documentation. Ini adalah good sign bahwa basic setup sudah proper dan kita ready untuk next steps.

Troubleshooting Common Issues

Kalau kamu encounter issues during setup process, berikut beberapa common problems dan solutions:

"PDO Connection failed" error biasanya indicates database connection issues. Double-check .env configuration, pastikan MySQL running, dan verify database exists dengan nama yang correct.

"Permission denied" errors biasanya terkait file permissions. Pastikan web server punya write access ke storage/ dan bootstrap/cache/ directories. Run chmod commands kalau diperlukan di Unix-based systems.

"Class not found" errors bisa indicate autoloading issues. Run composer dump-autoload untuk refresh autoload files, atau composer install kalau ada missing dependencies.

Port already in use ketika run php artisan serve bisa di-solve dengan specify different port: php artisan serve --port=8080.

Optimizing untuk Development

Untuk development experience yang optimal, kamu bisa enable beberapa Laravel debugging tools. Install Laravel Debugbar untuk detailed request information:

composer require barryvdh/laravel-debugbar --dev

Tool ini akan show detailed information tentang queries, performance metrics, dan request data yang sangat helpful during development process.

Setting Up Version Control

Sebelum kita lanjut ke coding features, good practice untuk setup Git repository:

git init
git add .
git commit -m "Initial Laravel 12 setup for SaaS Laundry Management"

Laravel automatically includes comprehensive .gitignore file yang exclude vendor/, node_modules/, dan files lain yang tidak should di-commit ke repository.

Dengan basic setup yang solid ini, kita sudah punya foundation yang strong untuk mulai develop fitur-fitur SaaS Laundry Management system. Database connection sudah established, basic table structure sudah ready, dan development environment fully functional.

Di artikel selanjutnya, kita akan mulai install dan configure Filament untuk admin dashboard yang powerful, serta setup basic authentication system yang akan menjadi backbone dari multi-tenant SaaS application kita.

Instalasi dan Konfigurasi Filament untuk Dashboard Admin SaaS Laundry

Nah sekarang kita masuk ke bagian yang exciting banget - instalasi Filament! Kalau kamu pernah struggle bikin admin dashboard dari scratch, Filament ini bakal jadi game-changer total. Package ini akan transform Laravel application kita menjadi admin panel yang super modern dan powerful dengan effort yang minimal. Di section ini, kita akan setup Filament dari awal sampai konfigurasi basic untuk manage data laundry business kita.

Mengapa Filament adalah Pilihan Terbaik

Sebelum kita diving ke installation, mari kita appreciate dulu kenapa Filament itu brilliant untuk project SaaS kita. Filament built dengan philosophy "convention over configuration" yang artinya banyak things yang works out of the box tanpa kita perlu setup manual. UI/UX yang dihasilkan itu modern banget dengan design system yang consistent, responsive mobile-first approach, dan accessibility yang proper.

Yang lebih keren lagi, Filament punya ecosystem yang complete - dari basic CRUD operations, advanced filtering dan searching, sampai complex widget system untuk analytics dashboard. Untuk SaaS laundry management kita, ini perfect banget karena kita butuh interface yang bisa handle multiple data types seperti customers, orders, employees, branches, dan financial reports dengan user experience yang intuitive.

Installing Filament Package

Mari kita mulai dengan install Filament package melalui Composer. Package utama yang kita butuhkan adalah filament/filament yang include semua core functionalities:

composer require filament/filament

Command ini akan download Filament beserta semua dependencies-nya. Process installation biasanya butuh beberapa menit karena Filament punya quite a few dependencies untuk UI components, form builders, dan table builders yang sophisticated.

Setelah installation selesai, kita perlu publish dan run migrations untuk Filament. Filament butuh beberapa database tables untuk storing configuration dan user preferences:

php artisan filament:install --panels

Command ini akan:

  • Publish Filament configuration files ke config/filament.php
  • Create default admin panel configuration
  • Setup routing untuk admin interface
  • Install necessary service providers

Kalau command di atas tidak available (tergantung Filament version), alternative command adalah:

php artisan vendor:publish --tag=filament-config
php artisan migrate

Konfigurasi Basic Filament

Setelah installation, kita perlu setup basic configuration di config/filament.php. File ini control berbagai aspects dari admin panel behavior:

<?php

return [
    'default' => env('FILAMENT_DEFAULT_PANEL', 'admin'),

    'panels' => [
        'admin' => [
            'id' => 'admin',
            'path' => env('FILAMENT_ADMIN_PATH', 'admin'),
            'domain' => env('FILAMENT_ADMIN_DOMAIN'),
            'homeUrl' => '/',
            'brandName' => 'SaaS Laundry Management',
            'brandLogo' => null,
            'brandLogoHeight' => '2rem',
            'favicon' => null,
            'colors' => [
                'primary' => 'blue',
                'gray' => 'slate',
            ],
            'font' => 'Inter',
            'middleware' => [
                'web',
                'auth:sanctum',
            ],
            'authGuard' => 'web',
            'pages' => [
                'health-check' => \\Filament\\Pages\\HealthCheckPage::class,
            ],
            'widgets' => [
                \\Filament\\Widgets\\StatsOverviewWidget::class,
                \\Filament\\Widgets\\ChartWidget::class,
            ],
        ],
    ],
];

Configuration ini set up admin panel di path /admin dengan branding untuk SaaS Laundry Management system kita. Kita juga define color scheme yang professional dan middleware yang appropriate untuk authentication.

Membuat User Admin Pertama

Sekarang kita perlu create admin user yang bisa access dashboard. Filament provide convenient command untuk ini:

php artisan make:filament-user

Command ini akan prompt kita untuk input user details:

Name: Super Admin
Email address: [email protected]
Password: [enter secure password]

Pastikan kamu remember credentials ini karena akan digunakan untuk first login ke admin dashboard. Email dan password ini akan di-store di users table dengan proper hashing untuk security.

Alternative approach, kita bisa create admin user via database seeder. Create file database/seeders/AdminSeeder.php:

<?php

namespace Database\\Seeders;

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

class AdminSeeder extends Seeder
{
    public function run()
    {
        User::firstOrCreate(
            ['email' => '[email protected]'],
            [
                'name' => 'Super Admin',
                'email' => '[email protected]',
                'password' => Hash::make('SecurePassword123!'),
                'email_verified_at' => now(),
            ]
        );
    }
}

Kemudian run seeder:

php artisan db:seed --class=AdminSeeder

Testing Admin Dashboard Access

Sekarang mari kita test apakah Filament sudah properly installed. Start development server kalau belum running:

php artisan serve

Buka browser dan navigate ke http://localhost:8000/admin. Kamu should melihat Filament login screen yang modern dan clean. Login menggunakan credentials yang kamu create tadi.

Setelah successful login, kamu akan redirected ke Filament dashboard yang show basic widgets dan navigation menu. Interface-nya sleek banget dengan sidebar navigation, user dropdown di top-right corner, dan main content area yang ready untuk kita customize.

Creating Basic Models untuk Laundry Business

Sebelum kita bisa manage data laundry di Filament, kita perlu create models yang represent business entities. Mari kita start dengan fundamental models:

Customer Model:

php artisan make:model Customer -m

Edit migration file database/migrations/create_customers_table.php:

<?php

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

return new class extends Migration
{
    public function up()
    {
        Schema::create('customers', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique()->nullable();
            $table->string('phone');
            $table->text('address');
            $table->enum('customer_type', ['regular', 'vip', 'corporate']);
            $table->decimal('total_spent', 10, 2)->default(0);
            $table->integer('total_orders')->default(0);
            $table->date('last_visit')->nullable();
            $table->timestamps();
        });
    }

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

LaundryOrder Model:

php artisan make:model LaundryOrder -m

Edit migration file untuk orders:

<?php

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

return new class extends Migration
{
    public function up()
    {
        Schema::create('laundry_orders', function (Blueprint $table) {
            $table->id();
            $table->string('order_number')->unique();
            $table->foreignId('customer_id')->constrained()->cascadeOnDelete();
            $table->foreignId('branch_id')->constrained()->cascadeOnDelete();
            $table->enum('service_type', ['wash_only', 'wash_iron', 'dry_clean', 'express']);
            $table->decimal('weight', 5, 2);
            $table->decimal('price_per_kg', 8, 2);
            $table->decimal('total_amount', 10, 2);
            $table->enum('status', ['pending', 'processing', 'ready', 'delivered', 'cancelled']);
            $table->datetime('pickup_date');
            $table->datetime('delivery_date')->nullable();
            $table->text('special_instructions')->nullable();
            $table->json('items_detail')->nullable();
            $table->timestamps();
        });
    }

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

Branch Model:

php artisan make:model Branch -m

Migration untuk branches:

<?php

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

return new class extends Migration
{
    public function up()
    {
        Schema::create('branches', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('code')->unique();
            $table->text('address');
            $table->string('phone');
            $table->string('manager_name');
            $table->time('opening_time');
            $table->time('closing_time');
            $table->json('operating_days'); // ['monday', 'tuesday', etc]
            $table->boolean('is_active')->default(true);
            $table->decimal('monthly_target', 12, 2)->default(0);
            $table->timestamps();
        });
    }

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

Run migrations untuk create tables:

php artisan migrate

Membuat Filament CRUD Resources untuk SaaS Laundry Management

Oke teman-teman, sekarang kita masuk ke bagian yang lebih exciting - membuat Filament Resources untuk semua entitas utama dalam sistem SaaS Laundry Management kita. Di section ini, kita akan create comprehensive CRUD operations dengan forms yang sophisticated, table dengan advanced filtering, dan custom actions yang akan make admin dashboard kita super powerful untuk daily operations.

Understanding Filament Resource Architecture

Sebelum kita dive into coding, penting untuk understand bagaimana Filament Resource architecture bekerja. Setiap Resource terdiri dari beberapa components utama: Form schema untuk create/edit operations, Table configuration untuk listing dan bulk actions, Pages untuk handling different views, dan Actions untuk custom operations yang bisa di-trigger dari UI.

Yang bikin Filament powerful adalah kemampuannya untuk automatically generate UI berdasarkan configuration yang kita define. Tapi jangan salah, flexibility-nya juga luar biasa - kita bisa customize hampir semua aspects dari UI dan behavior sesuai business requirements yang specific.

Creating LaundryService Resource

Mari kita mulai dengan LaundryService resource yang akan handle pricing dan service types. First, create the model dan migration:

php artisan make:model LaundryService -m

Edit migration file:

<?php

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

return new class extends Migration
{
    public function up()
    {
        Schema::create('laundry_services', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('code')->unique();
            $table->text('description')->nullable();
            $table->decimal('price_per_kg', 8, 2);
            $table->decimal('express_multiplier', 3, 2)->default(1.5);
            $table->integer('standard_duration_hours')->default(24);
            $table->integer('express_duration_hours')->default(6);
            $table->boolean('is_active')->default(true);
            $table->json('available_branches')->nullable();
            $table->timestamps();
        });
    }

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

Sekarang create Filament Resource:

php artisan make:filament-resource LaundryService

Edit app/Filament/Resources/LaundryServiceResource.php:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\LaundryServiceResource\\Pages;
use App\\Models\\LaundryService;
use App\\Models\\Branch;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Tables\\Filters\\Filter;
use Illuminate\\Database\\Eloquent\\Builder;

class LaundryServiceResource extends Resource
{
    protected static ?string $model = LaundryService::class;
    protected static ?string $navigationIcon = 'heroicon-o-sparkles';
    protected static ?string $navigationGroup = 'Service Management';
    protected static ?string $navigationLabel = 'Laundry Services';

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Basic Information')
                    ->schema([
                        Forms\\Components\\TextInput::make('name')
                            ->required()
                            ->maxLength(255)
                            ->live(onBlur: true)
                            ->afterStateUpdated(fn (string $context, $state, callable $set) =>
                                $context === 'create' ? $set('code', str($state)->slug()->upper()) : null
                            ),
                        Forms\\Components\\TextInput::make('code')
                            ->required()
                            ->unique(ignoreRecord: true)
                            ->maxLength(20)
                            ->uppercase()
                            ->alphaDash(),
                        Forms\\Components\\Textarea::make('description')
                            ->rows(3)
                            ->maxLength(500),
                        Forms\\Components\\Toggle::make('is_active')
                            ->default(true)
                            ->helperText('Inactive services will not be available for new orders'),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Pricing & Duration')
                    ->schema([
                        Forms\\Components\\TextInput::make('price_per_kg')
                            ->required()
                            ->numeric()
                            ->prefix('IDR')
                            ->minValue(0)
                            ->step(500)
                            ->helperText('Base price per kilogram'),
                        Forms\\Components\\TextInput::make('express_multiplier')
                            ->required()
                            ->numeric()
                            ->minValue(1)
                            ->maxValue(5)
                            ->step(0.1)
                            ->default(1.5)
                            ->helperText('Multiplier for express service pricing'),
                        Forms\\Components\\TextInput::make('standard_duration_hours')
                            ->required()
                            ->numeric()
                            ->minValue(1)
                            ->maxValue(168)
                            ->default(24)
                            ->suffix('hours')
                            ->helperText('Standard completion time'),
                        Forms\\Components\\TextInput::make('express_duration_hours')
                            ->required()
                            ->numeric()
                            ->minValue(1)
                            ->maxValue(72)
                            ->default(6)
                            ->suffix('hours')
                            ->helperText('Express completion time'),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Branch Availability')
                    ->schema([
                        Forms\\Components\\CheckboxList::make('available_branches')
                            ->options(fn () => Branch::where('is_active', true)->pluck('name', 'id'))
                            ->columns(3)
                            ->helperText('Select branches where this service is available. Leave empty for all branches.'),
                    ]),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('name')
                    ->searchable()
                    ->sortable()
                    ->weight('medium'),
                Tables\\Columns\\TextColumn::make('code')
                    ->badge()
                    ->color('gray')
                    ->searchable(),
                Tables\\Columns\\TextColumn::make('price_per_kg')
                    ->money('IDR')
                    ->sortable()
                    ->color('success'),
                Tables\\Columns\\TextColumn::make('express_multiplier')
                    ->suffix('x')
                    ->color('warning'),
                Tables\\Columns\\TextColumn::make('standard_duration_hours')
                    ->suffix('h')
                    ->label('Duration'),
                Tables\\Columns\\IconColumn::make('is_active')
                    ->boolean()
                    ->trueColor('success')
                    ->falseColor('danger'),
                Tables\\Columns\\TextColumn::make('created_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                Tables\\Filters\\TernaryFilter::make('is_active')
                    ->placeholder('All services')
                    ->trueLabel('Active only')
                    ->falseLabel('Inactive only'),
                Filter::make('price_range')
                    ->form([
                        Forms\\Components\\TextInput::make('price_from')
                            ->numeric()
                            ->prefix('IDR'),
                        Forms\\Components\\TextInput::make('price_to')
                            ->numeric()
                            ->prefix('IDR'),
                    ])
                    ->query(function (Builder $query, array $data): Builder {
                        return $query
                            ->when(
                                $data['price_from'],
                                fn (Builder $query, $price): Builder => $query->where('price_per_kg', '>=', $price),
                            )
                            ->when(
                                $data['price_to'],
                                fn (Builder $query, $price): Builder => $query->where('price_per_kg', '<=', $price),
                            );
                    }),
            ])
            ->actions([
                Tables\\Actions\\Action::make('toggle_status')
                    ->icon(fn (LaundryService $record) => $record->is_active ? 'heroicon-o-pause' : 'heroicon-o-play')
                    ->color(fn (LaundryService $record) => $record->is_active ? 'warning' : 'success')
                    ->action(fn (LaundryService $record) => $record->update(['is_active' => !$record->is_active]))
                    ->requiresConfirmation(),
                Tables\\Actions\\EditAction::make(),
                Tables\\Actions\\DeleteAction::make(),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make(),
                    Tables\\Actions\\BulkAction::make('activate')
                        ->icon('heroicon-o-check-circle')
                        ->color('success')
                        ->action(fn ($records) => $records->each->update(['is_active' => true]))
                        ->deselectRecordsAfterCompletion(),
                    Tables\\Actions\\BulkAction::make('deactivate')
                        ->icon('heroicon-o-x-circle')
                        ->color('danger')
                        ->action(fn ($records) => $records->each->update(['is_active' => false]))
                        ->deselectRecordsAfterCompletion(),
                ]),
            ]);
    }

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

Creating Employee Resource

Next, kita buat Employee resource untuk manage staff. Create model dan migration:

php artisan make:model Employee -m

Migration file:

<?php

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

return new class extends Migration
{
    public function up()
    {
        Schema::create('employees', function (Blueprint $table) {
            $table->id();
            $table->string('employee_id')->unique();
            $table->string('name');
            $table->string('email')->unique()->nullable();
            $table->string('phone');
            $table->text('address');
            $table->enum('position', ['manager', 'supervisor', 'operator', 'driver', 'customer_service']);
            $table->foreignId('branch_id')->constrained()->cascadeOnDelete();
            $table->decimal('basic_salary', 10, 2);
            $table->decimal('commission_rate', 5, 2)->default(0);
            $table->date('hire_date');
            $table->date('termination_date')->nullable();
            $table->enum('status', ['active', 'inactive', 'terminated']);
            $table->json('work_schedule')->nullable();
            $table->timestamps();
        });
    }

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

Create Filament Resource:

php artisan make:filament-resource Employee

Edit EmployeeResource:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\EmployeeResource\\Pages;
use App\\Models\\Employee;
use App\\Models\\Branch;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;

class EmployeeResource extends Resource
{
    protected static ?string $model = Employee::class;
    protected static ?string $navigationIcon = 'heroicon-o-users';
    protected static ?string $navigationGroup = 'HR Management';

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Personal Information')
                    ->schema([
                        Forms\\Components\\TextInput::make('employee_id')
                            ->required()
                            ->unique(ignoreRecord: true)
                            ->maxLength(20)
                            ->default(fn () => 'EMP' . str_pad(random_int(1, 9999), 4, '0', STR_PAD_LEFT)),
                        Forms\\Components\\TextInput::make('name')
                            ->required()
                            ->maxLength(255),
                        Forms\\Components\\TextInput::make('email')
                            ->email()
                            ->unique(ignoreRecord: true)
                            ->maxLength(255),
                        Forms\\Components\\TextInput::make('phone')
                            ->tel()
                            ->required()
                            ->maxLength(20),
                        Forms\\Components\\Textarea::make('address')
                            ->required()
                            ->rows(3),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Employment Details')
                    ->schema([
                        Forms\\Components\\Select::make('branch_id')
                            ->relationship('branch', 'name')
                            ->required()
                            ->searchable()
                            ->preload(),
                        Forms\\Components\\Select::make('position')
                            ->options([
                                'manager' => 'Manager',
                                'supervisor' => 'Supervisor',
                                'operator' => 'Operator',
                                'driver' => 'Driver',
                                'customer_service' => 'Customer Service',
                            ])
                            ->required(),
                        Forms\\Components\\DatePicker::make('hire_date')
                            ->required()
                            ->default(now()),
                        Forms\\Components\\DatePicker::make('termination_date')
                            ->nullable(),
                        Forms\\Components\\Select::make('status')
                            ->options([
                                'active' => 'Active',
                                'inactive' => 'Inactive',
                                'terminated' => 'Terminated',
                            ])
                            ->required()
                            ->default('active'),
                    ])
                    ->columns(3),

                Forms\\Components\\Section::make('Compensation')
                    ->schema([
                        Forms\\Components\\TextInput::make('basic_salary')
                            ->required()
                            ->numeric()
                            ->prefix('IDR')
                            ->minValue(0),
                        Forms\\Components\\TextInput::make('commission_rate')
                            ->numeric()
                            ->suffix('%')
                            ->minValue(0)
                            ->maxValue(100)
                            ->default(0)
                            ->helperText('Commission percentage on orders'),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Work Schedule')
                    ->schema([
                        Forms\\Components\\Repeater::make('work_schedule')
                            ->schema([
                                Forms\\Components\\Select::make('day')
                                    ->options([
                                        'monday' => 'Monday',
                                        'tuesday' => 'Tuesday',
                                        'wednesday' => 'Wednesday',
                                        'thursday' => 'Thursday',
                                        'friday' => 'Friday',
                                        'saturday' => 'Saturday',
                                        'sunday' => 'Sunday',
                                    ])
                                    ->required(),
                                Forms\\Components\\TimePicker::make('start_time')
                                    ->required(),
                                Forms\\Components\\TimePicker::make('end_time')
                                    ->required(),
                            ])
                            ->columns(3)
                            ->defaultItems(0)
                            ->collapsible(),
                    ]),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('employee_id')
                    ->badge()
                    ->searchable(),
                Tables\\Columns\\TextColumn::make('name')
                    ->searchable()
                    ->sortable(),
                Tables\\Columns\\TextColumn::make('position')
                    ->badge()
                    ->color(fn (string $state): string => match ($state) {
                        'manager' => 'success',
                        'supervisor' => 'warning',
                        'operator' => 'info',
                        'driver' => 'gray',
                        'customer_service' => 'primary',
                    }),
                Tables\\Columns\\TextColumn::make('branch.name')
                    ->searchable()
                    ->sortable(),
                Tables\\Columns\\TextColumn::make('basic_salary')
                    ->money('IDR')
                    ->sortable(),
                Tables\\Columns\\TextColumn::make('status')
                    ->badge()
                    ->color(fn (string $state): string => match ($state) {
                        'active' => 'success',
                        'inactive' => 'warning',
                        'terminated' => 'danger',
                    }),
                Tables\\Columns\\TextColumn::make('hire_date')
                    ->date()
                    ->sortable(),
            ])
            ->filters([
                Tables\\Filters\\SelectFilter::make('position')
                    ->options([
                        'manager' => 'Manager',
                        'supervisor' => 'Supervisor',
                        'operator' => 'Operator',
                        'driver' => 'Driver',
                        'customer_service' => 'Customer Service',
                    ]),
                Tables\\Filters\\SelectFilter::make('status')
                    ->options([
                        'active' => 'Active',
                        'inactive' => 'Inactive',
                        'terminated' => 'Terminated',
                    ]),
                Tables\\Filters\\SelectFilter::make('branch')
                    ->relationship('branch', 'name')
                    ->searchable()
                    ->preload(),
            ])
            ->actions([
                Tables\\Actions\\Action::make('change_status')
                    ->icon('heroicon-o-arrow-path')
                    ->form([
                        Forms\\Components\\Select::make('status')
                            ->options([
                                'active' => 'Active',
                                'inactive' => 'Inactive',
                                'terminated' => 'Terminated',
                            ])
                            ->required(),
                        Forms\\Components\\DatePicker::make('termination_date')
                            ->visible(fn (Forms\\Get $get) => $get('status') === 'terminated'),
                    ])
                    ->action(function (Employee $record, array $data) {
                        $record->update([
                            'status' => $data['status'],
                            'termination_date' => $data['status'] === 'terminated' ? $data['termination_date'] : null,
                        ]);
                    }),
                Tables\\Actions\\EditAction::make(),
                Tables\\Actions\\DeleteAction::make(),
            ]);
    }

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

Creating Order Resource dengan Advanced Features

Sekarang kita create OrderResource yang complex dengan status management. First, update LaundryOrder model dengan relationships:

<?php

namespace App\\Models;

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

class LaundryOrder extends Model
{
    use HasFactory;

    protected $fillable = [
        'order_number',
        'customer_id',
        'branch_id',
        'service_type',
        'weight',
        'price_per_kg',
        'total_amount',
        'status',
        'pickup_date',
        'delivery_date',
        'special_instructions',
        'items_detail',
    ];

    protected $casts = [
        'pickup_date' => 'datetime',
        'delivery_date' => 'datetime',
        'items_detail' => 'array',
        'weight' => 'decimal:2',
        'price_per_kg' => 'decimal:2',
        'total_amount' => 'decimal:2',
    ];

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

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

    public function laundryService(): BelongsTo
    {
        return $this->belongsTo(LaundryService::class, 'service_type', 'code');
    }
}

Create OrderResource:

php artisan make:filament-resource LaundryOrder --generate

Edit LaundryOrderResource:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\LaundryOrderResource\\Pages;
use App\\Models\\LaundryOrder;
use App\\Models\\Customer;
use App\\Models\\Branch;
use App\\Models\\LaundryService;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Notifications\\Notification;

class LaundryOrderResource extends Resource
{
    protected static ?string $model = LaundryOrder::class;
    protected static ?string $navigationIcon = 'heroicon-o-shopping-bag';
    protected static ?string $navigationGroup = 'Order Management';
    protected static ?string $navigationLabel = 'Orders';

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Order Information')
                    ->schema([
                        Forms\\Components\\TextInput::make('order_number')
                            ->required()
                            ->unique(ignoreRecord: true)
                            ->default(fn () => 'ORD-' . date('Ymd') . '-' . str_pad(random_int(1, 9999), 4, '0', STR_PAD_LEFT))
                            ->maxLength(50),
                        Forms\\Components\\Select::make('customer_id')
                            ->relationship('customer', 'name')
                            ->searchable()
                            ->preload()
                            ->required()
                            ->createOptionForm([
                                Forms\\Components\\TextInput::make('name')->required(),
                                Forms\\Components\\TextInput::make('phone')->required(),
                                Forms\\Components\\TextInput::make('email')->email(),
                                Forms\\Components\\Textarea::make('address')->required(),
                            ]),
                        Forms\\Components\\Select::make('branch_id')
                            ->relationship('branch', 'name')
                            ->required()
                            ->searchable()
                            ->preload(),
                        Forms\\Components\\Select::make('status')
                            ->options([
                                'pending' => 'Pending',
                                'processing' => 'Processing',
                                'ready' => 'Ready',
                                'delivered' => 'Delivered',
                                'cancelled' => 'Cancelled',
                            ])
                            ->required()
                            ->default('pending'),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Service Details')
                    ->schema([
                        Forms\\Components\\Select::make('service_type')
                            ->options(fn () => LaundryService::where('is_active', true)->pluck('name', 'code'))
                            ->required()
                            ->reactive()
                            ->afterStateUpdated(function ($state, callable $set) {
                                if ($state) {
                                    $service = LaundryService::where('code', $state)->first();
                                    if ($service) {
                                        $set('price_per_kg', $service->price_per_kg);
                                    }
                                }
                            }),
                        Forms\\Components\\TextInput::make('weight')
                            ->required()
                            ->numeric()
                            ->suffix('kg')
                            ->minValue(0.1)
                            ->step(0.1)
                            ->reactive()
                            ->afterStateUpdated(function ($state, callable $set, callable $get) {
                                $weight = floatval($state);
                                $pricePerKg = floatval($get('price_per_kg'));
                                $set('total_amount', $weight * $pricePerKg);
                            }),
                        Forms\\Components\\TextInput::make('price_per_kg')
                            ->required()
                            ->numeric()
                            ->prefix('IDR')
                            ->reactive()
                            ->afterStateUpdated(function ($state, callable $set, callable $get) {
                                $weight = floatval($get('weight'));
                                $pricePerKg = floatval($state);
                                $set('total_amount', $weight * $pricePerKg);
                            }),
                        Forms\\Components\\TextInput::make('total_amount')
                            ->required()
                            ->numeric()
                            ->prefix('IDR')
                            ->readOnly(),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Schedule & Notes')
                    ->schema([
                        Forms\\Components\\DateTimePicker::make('pickup_date')
                            ->required()
                            ->default(now()),
                        Forms\\Components\\DateTimePicker::make('delivery_date')
                            ->nullable(),
                        Forms\\Components\\Textarea::make('special_instructions')
                            ->rows(3)
                            ->maxLength(500),
                        Forms\\Components\\Repeater::make('items_detail')
                            ->schema([
                                Forms\\Components\\TextInput::make('item_type')
                                    ->required()
                                    ->placeholder('e.g., Shirt, Pants, Dress'),
                                Forms\\Components\\TextInput::make('quantity')
                                    ->required()
                                    ->numeric()
                                    ->minValue(1),
                                Forms\\Components\\TextInput::make('condition')
                                    ->placeholder('e.g., Stained, Normal, Torn'),
                            ])
                            ->columns(3)
                            ->defaultItems(1)
                            ->collapsible(),
                    ]),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('order_number')
                    ->searchable()
                    ->sortable()
                    ->copyable(),
                Tables\\Columns\\TextColumn::make('customer.name')
                    ->searchable()
                    ->sortable(),
                Tables\\Columns\\TextColumn::make('branch.name')
                    ->searchable(),
                Tables\\Columns\\TextColumn::make('service_type')
                    ->badge(),
                Tables\\Columns\\TextColumn::make('weight')
                    ->suffix(' kg')
                    ->sortable(),
                Tables\\Columns\\TextColumn::make('total_amount')
                    ->money('IDR')
                    ->sortable(),
                Tables\\Columns\\TextColumn::make('status')
                    ->badge()
                    ->color(fn (string $state): string => match ($state) {
                        'pending' => 'warning',
                        'processing' => 'info',
                        'ready' => 'success',
                        'delivered' => 'primary',
                        'cancelled' => 'danger',
                    }),
                Tables\\Columns\\TextColumn::make('pickup_date')
                    ->dateTime()
                    ->sortable(),
                Tables\\Columns\\TextColumn::make('delivery_date')
                    ->dateTime()
                    ->sortable()
                    ->placeholder('Not set'),
            ])
            ->filters([
                Tables\\Filters\\SelectFilter::make('status')
                    ->options([
                        'pending' => 'Pending',
                        'processing' => 'Processing',
                        'ready' => 'Ready',
                        'delivered' => 'Delivered',
                        'cancelled' => 'Cancelled',
                    ]),
                Tables\\Filters\\SelectFilter::make('branch')
                    ->relationship('branch', 'name'),
                Tables\\Filters\\Filter::make('pickup_date')
                    ->form([
                        Forms\\Components\\DatePicker::make('pickup_from'),
                        Forms\\Components\\DatePicker::make('pickup_until'),
                    ])
                    ->query(function ($query, array $data) {
                        return $query
                            ->when($data['pickup_from'], fn ($q) => $q->whereDate('pickup_date', '>=', $data['pickup_from']))
                            ->when($data['pickup_until'], fn ($q) => $q->whereDate('pickup_date', '<=', $data['pickup_until']));
                    }),
            ])
            ->actions([
                Tables\\Actions\\Action::make('update_status')
                    ->icon('heroicon-o-arrow-path')
                    ->color('primary')
                    ->form([
                        Forms\\Components\\Select::make('status')
                            ->options([
                                'pending' => 'Pending',
                                'processing' => 'Processing',
                                'ready' => 'Ready',
                                'delivered' => 'Delivered',
                                'cancelled' => 'Cancelled',
                            ])
                            ->required(),
                        Forms\\Components\\DateTimePicker::make('delivery_date')
                            ->visible(fn (Forms\\Get $get) => in_array($get('status'), ['ready', 'delivered'])),
                    ])
                    ->action(function (LaundryOrder $record, array $data) {
                        $record->update([
                            'status' => $data['status'],
                            'delivery_date' => $data['delivery_date'] ?? $record->delivery_date,
                        ]);

                        Notification::make()
                            ->title('Status Updated')
                            ->body("Order {$record->order_number} status changed to {$data['status']}")
                            ->success()
                            ->send();
                    }),
                Tables\\Actions\\EditAction::make(),
                Tables\\Actions\\DeleteAction::make(),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make(),
                    Tables\\Actions\\BulkAction::make('mark_as_processing')
                        ->icon('heroicon-o-cog-6-tooth')
                        ->color('info')
                        ->action(fn ($records) => $records->each->update(['status' => 'processing']))
                        ->deselectRecordsAfterCompletion(),
                ]),
            ]);
    }

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

Creating SaaS Plan dan Subscription Resources

Sekarang kita create resources untuk SaaS functionality. Create Plan model:

php artisan make:model Plan -m

Migration:

<?php

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

return new class extends Migration
{
    public function up()
    {
        Schema::create('plans', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description');
            $table->decimal('monthly_price', 10, 2);
            $table->decimal('yearly_price', 10, 2);
            $table->integer('max_branches');
            $table->integer('max_employees');
            $table->integer('max_orders_per_month');
            $table->json('features');
            $table->boolean('is_popular')->default(false);
            $table->boolean('is_active')->default(true);
            $table->timestamps();
        });
    }

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

Create PlanResource:

php artisan make:filament-resource Plan

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\PlanResource\\Pages;
use App\\Models\\Plan;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;

class PlanResource extends Resource
{
    protected static ?string $model = Plan::class;
    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
    protected static ?string $navigationGroup = 'SaaS Management';

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Plan Details')
                    ->schema([
                        Forms\\Components\\TextInput::make('name')
                            ->required()
                            ->live(onBlur: true)
                            ->afterStateUpdated(fn ($state, callable $set) =>
                                $set('slug', str($state)->slug())
                            ),
                        Forms\\Components\\TextInput::make('slug')
                            ->required()
                            ->unique(ignoreRecord: true),
                        Forms\\Components\\Textarea::make('description')
                            ->required()
                            ->rows(3),
                        Forms\\Components\\Toggle::make('is_popular')
                            ->helperText('Mark as popular plan (featured in pricing)'),
                        Forms\\Components\\Toggle::make('is_active')
                            ->default(true),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Pricing')
                    ->schema([
                        Forms\\Components\\TextInput::make('monthly_price')
                            ->required()
                            ->numeric()
                            ->prefix('IDR'),
                        Forms\\Components\\TextInput::make('yearly_price')
                            ->required()
                            ->numeric()
                            ->prefix('IDR')
                            ->helperText('Usually 10-20% discount from 12x monthly price'),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Limits')
                    ->schema([
                        Forms\\Components\\TextInput::make('max_branches')
                            ->required()
                            ->numeric()
                            ->minValue(1),
                        Forms\\Components\\TextInput::make('max_employees')
                            ->required()
                            ->numeric()
                            ->minValue(1),
                        Forms\\Components\\TextInput::make('max_orders_per_month')
                            ->required()
                            ->numeric()
                            ->minValue(1),
                    ])
                    ->columns(3),

                Forms\\Components\\Section::make('Features')
                    ->schema([
                        Forms\\Components\\CheckboxList::make('features')
                            ->options([
                                'advanced_reporting' => 'Advanced Reporting',
                                'api_access' => 'API Access',
                                'white_labeling' => 'White Labeling',
                                'priority_support' => 'Priority Support',
                                'custom_integrations' => 'Custom Integrations',
                                'multi_currency' => 'Multi Currency',
                                'inventory_management' => 'Inventory Management',
                                'loyalty_program' => 'Loyalty Program',
                            ])
                            ->columns(2),
                    ]),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('name')
                    ->searchable()
                    ->sortable(),
                Tables\\Columns\\TextColumn::make('monthly_price')
                    ->money('IDR')
                    ->sortable(),
                Tables\\Columns\\TextColumn::make('yearly_price')
                    ->money('IDR')
                    ->sortable(),
                Tables\\Columns\\TextColumn::make('max_branches')
                    ->suffix(' branches'),
                Tables\\Columns\\IconColumn::make('is_popular')
                    ->boolean()
                    ->trueIcon('heroicon-o-star')
                    ->falseIcon('heroicon-o-star')
                    ->trueColor('warning'),
                Tables\\Columns\\IconColumn::make('is_active')
                    ->boolean(),
            ])
            ->actions([
                Tables\\Actions\\EditAction::make(),
                Tables\\Actions\\DeleteAction::make(),
            ]);
    }

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

Dengan setup resources yang comprehensive ini, admin dashboard kita sudah punya foundation yang solid untuk manage semua aspects dari SaaS Laundry business. Setiap resource dilengkapi dengan advanced filtering, custom actions, dan user experience yang intuitive untuk daily operations.

Implementasi Multi-Role Authentication untuk SaaS Laundry Management

Sekarang kita masuk ke salah satu bagian paling crucial dari aplikasi SaaS - authentication system yang robust dengan multi-role access control. Di section ini, kita akan implement authentication menggunakan Laravel Fortify yang powerful, setup role-based access control, dan create seamless user experience untuk berbagai types of users dalam ecosystem SaaS Laundry Management kita.

Mengapa Laravel Fortify untuk SaaS Application

Laravel Fortify adalah pilihan yang excellent untuk SaaS application karena provide backend authentication features tanpa prescriptive frontend. Ini perfect untuk kita yang sudah menggunakan Filament untuk admin interface, karena Fortify fokus ke business logic authentication sementara UI bisa kita customize sesuai needs.

Fortify support features yang essential untuk SaaS seperti registration, authentication, email verification, password reset, two-factor authentication, dan session management. Yang lebih important, Fortify designed dengan customization flexibility yang tinggi, allowing kita untuk implement complex business rules seperti multi-tenant registration dan role-based redirections.

Installing dan Configuring Laravel Fortify

Mari kita mulai dengan install Laravel Fortify via Composer:

composer require laravel/fortify

Setelah installation, publish Fortify configuration dan migration files:

php artisan vendor:publish --provider="Laravel\\Fortify\\FortifyServiceProvider"

Command ini akan create config/fortify.php file dan migration untuk two-factor authentication tables. Sekarang kita perlu register FortifyServiceProvider di config/app.php:

<?php

return [
    // ... other configuration

    'providers' => [
        // ... other service providers
        App\\Providers\\FortifyServiceProvider::class,
    ],
];

Run migrations untuk create necessary tables:

php artisan migrate

Setting Up Multi-Role System dengan Spatie Permission

Untuk implement robust role system, kita akan leverage Spatie Permission package yang integrate seamlessly dengan Laravel authentication:

composer require spatie/laravel-permission

Publish migration files:

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

Run migrations:

php artisan migrate

Update User model untuk use Spatie Permission traits:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Notifications\\Notifiable;
use Laravel\\Fortify\\TwoFactorAuthenticatable;
use Laravel\\Sanctum\\HasApiTokens;
use Spatie\\Permission\\Traits\\HasRoles;

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

    protected $fillable = [
        'name',
        'email',
        'password',
        'phone',
        'company_name',
        'subscription_plan',
        'subscription_status',
        'trial_ends_at',
        'email_verified_at',
    ];

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

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

    // Relationship untuk SaaS multi-tenancy
    public function branches()
    {
        return $this->hasMany(Branch::class, 'owner_id');
    }

    public function subscription()
    {
        return $this->hasOne(Subscription::class);
    }

    // Helper methods untuk role cheacking
    public function isSuperAdmin(): bool
    {
        return $this->hasRole('super_admin');
    }

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

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

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

Create database migration untuk additional user fields:

php artisan make:migration add_saas_fields_to_users_table --table=users

Migration content:

<?php

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

return new class extends Migration
{
    public function up()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('phone')->nullable()->after('email');
            $table->string('company_name')->nullable()->after('phone');
            $table->string('subscription_plan')->nullable()->after('company_name');
            $table->enum('subscription_status', ['trial', 'active', 'cancelled', 'expired'])
                  ->default('trial')->after('subscription_plan');
            $table->timestamp('trial_ends_at')->nullable()->after('subscription_status');
        });
    }

    public function down()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn([
                'phone',
                'company_name',
                'subscription_plan',
                'subscription_status',
                'trial_ends_at'
            ]);
        });
    }
};

Creating Roles dan Permissions Seeder

Create seeder untuk setup basic roles dan peermissions:

php artisan make:seeder RolesAndPermissionsSeeder

Edit database/seeders/RolesAndPermissionsSeeder.php:

<?php

namespace Database\\Seeders;

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

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

        // Create permissions for different modules
        $permissions = [
            // Customer management
            'view customers',
            'create customers',
            'edit customers',
            'delete customers',

            // Order management
            'view orders',
            'create orders',
            'edit orders',
            'delete orders',
            'update order status',

            // Employee management
            'view employees',
            'create employees',
            'edit employees',
            'delete employees',

            // Branch management
            'view branches',
            'create branches',
            'edit branches',
            'delete branches',

            // Financial reports
            'view financial reports',
            'export financial reports',

            // SaaS management
            'manage subscriptions',
            'manage plans',
            'view all tenants',
            'system settings',

            // Service management
            'view services',
            'create services',
            'edit services',
            'delete services',
        ];

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

        // Create roles and assign permissions

        // Super Admin - full access to SaaS platform
        $superAdmin = Role::create(['name' => 'super_admin']);
        $superAdmin->givePermissionTo(Permission::all());

        // Business Owner - owns laundry business, can manage all aspects
        $businessOwner = Role::create(['name' => 'business_owner']);
        $businessOwner->givePermissionTo([
            'view customers', 'create customers', 'edit customers', 'delete customers',
            'view orders', 'create orders', 'edit orders', 'delete orders', 'update order status',
            'view employees', 'create employees', 'edit employees', 'delete employees',
            'view branches', 'create branches', 'edit branches', 'delete branches',
            'view financial reports', 'export financial reports',
            'view services', 'create services', 'edit services', 'delete services',
        ]);

        // Branch Manager - manages specific branch
        $branchManager = Role::create(['name' => 'branch_manager']);
        $branchManager->givePermissionTo([
            'view customers', 'create customers', 'edit customers',
            'view orders', 'create orders', 'edit orders', 'update order status',
            'view employees', 'edit employees',
            'view financial reports',
            'view services',
        ]);

        // Cashier - handles daily operations
        $cashier = Role::create(['name' => 'cashier']);
        $cashier->givePermissionTo([
            'view customers', 'create customers', 'edit customers',
            'view orders', 'create orders', 'edit orders', 'update order status',
            'view services',
        ]);

        // Create default super admin user
        $superAdminUser = User::create([
            'name' => 'Super Administrator',
            'email' => '[email protected]',
            'password' => bcrypt('SecurePassword123!'),
            'email_verified_at' => now(),
        ]);
        $superAdminUser->assignRole('super_admin');

        // Create sample business owner
        $businessOwnerUser = User::create([
            'name' => 'John BWA',
            'email' => '[email protected]',
            'password' => bcrypt('password123'),
            'phone' => '+62812345678',
            'company_name' => 'Laundry ABC',
            'subscription_plan' => 'professional',
            'subscription_status' => 'trial',
            'trial_ends_at' => now()->addDays(14),
            'email_verified_at' => now(),
        ]);
        $businessOwnerUser->assignRole('business_owner');
    }
}

Update DatabaseSeeder:

<?php

namespace Database\\Seeders;

use Illuminate\\Database\\Seeder;

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

Run seeder:

php artisan db:seed --class=RolesAndPermissionsSeeder

Configuring Fortify untuk Custom Authentication Flow

Edit config/fortify.php untuk enable features yang kita butuhkan:

<?php

return [
    'guard' => 'web',
    'middleware' => ['web'],
    'passwords' => 'users',
    'username' => 'email',
    'email' => 'email',
    'lowercase_usernames' => true,
    'home' => '/dashboard',

    'features' => [
        Features::registration(),
        Features::resetPasswords(),
        Features::emailVerification(),
        Features::updateProfileInformation(),
        Features::updatePasswords(),
        Features::twoFactorAuthentication([
            'confirm' => true,
            'confirmPassword' => true,
        ]),
    ],

    'views' => true,
    'redirects' => [
        'login' => '/dashboard',
        'logout' => '/',
        'password-confirmation' => '/dashboard',
        'register' => '/dashboard',
        'email-verification' => '/dashboard',
        'password-reset' => '/login',
    ],
];

Creating Custom Fortify Service Provider

Edit app/Providers/FortifyServiceProvider.php untuk customize authentication behavior:

<?php

namespace App\\Providers;

use App\\Actions\\Fortify\\CreateNewUser;
use App\\Actions\\Fortify\\ResetUserPassword;
use App\\Actions\\Fortify\\UpdateUserPassword;
use App\\Actions\\Fortify\\UpdateUserProfileInformation;
use Illuminate\\Cache\\RateLimiting\\Limit;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\RateLimiter;
use Illuminate\\Support\\ServiceProvider;
use Laravel\\Fortify\\Fortify;
use Laravel\\Fortify\\Contracts\\LoginResponse;
use Laravel\\Fortify\\Contracts\\RegisterResponse;

class FortifyServiceProvider extends ServiceProvider
{
    public function register()
    {
        // Custom login response untuk multi-role redirect
        $this->app->instance(LoginResponse::class, new class implements LoginResponse {
            public function toResponse($request)
            {
                $user = auth()->user();

                // Redirect berdasarkan role
                if ($user->isSuperAdmin()) {
                    return redirect('/admin/dashboard');
                } elseif ($user->isBusinessOwner()) {
                    return redirect('/admin/dashboard');
                } elseif ($user->isBranchManager()) {
                    return redirect('/admin/orders');
                } elseif ($user->isCashier()) {
                    return redirect('/admin/orders');
                }

                return redirect('/dashboard');
            }
        });

        // Custom register response
        $this->app->instance(RegisterResponse::class, new class implements RegisterResponse {
            public function toResponse($request)
            {
                return redirect('/admin/dashboard');
            }
        });
    }

    public function boot()
    {
        Fortify::createUsersUsing(CreateNewUser::class);
        Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
        Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
        Fortify::resetUserPasswordsUsing(ResetUserPassword::class);

        // Custom authentication logic
        Fortify::authenticateUsing(function (Request $request) {
            $user = \\App\\Models\\User::where('email', $request->email)->first();

            if ($user &&
                \\Hash::check($request->password, $user->password)) {

                // Check subscription status untuk business users
                if (!$user->isSuperAdmin() && $user->subscription_status === 'expired') {
                    throw \\Illuminate\\Validation\\ValidationException::withMessages([
                        'email' => ['Your subscription has expired. Please contact support.'],
                    ]);
                }

                return $user;
            }
        });

        // Rate limiting
        RateLimiter::for('login', function (Request $request) {
            $email = (string) $request->email;
            return Limit::perMinute(5)->by($email . $request->ip());
        });

        RateLimiter::for('two-factor', function (Request $request) {
            return Limit::perMinute(5)->by($request->session()->get('login.id'));
        });

        // Custom views
        Fortify::loginView(function () {
            return view('auth.login');
        });

        Fortify::registerView(function () {
            return view('auth.register');
        });

        Fortify::requestPasswordResetLinkView(function () {
            return view('auth.forgot-password');
        });

        Fortify::resetPasswordView(function ($request) {
            return view('auth.reset-password', ['request' => $request]);
        });

        Fortify::verifyEmailView(function () {
            return view('auth.verify-email');
        });

        Fortify::confirmPasswordView(function () {
            return view('auth.confirm-password');
        });

        Fortify::twoFactorChallengeView(function () {
            return view('auth.two-factor-challenge');
        });
    }
}

Creating Custom User Registration Action

Edit app/Actions/Fortify/CreateNewUser.php untuk handle SaaS registration:

<?php

namespace App\\Actions\\Fortify;

use App\\Models\\User;
use Illuminate\\Support\\Facades\\Hash;
use Illuminate\\Support\\Facades\\Validator;
use Laravel\\Fortify\\Contracts\\CreatesNewUsers;

class CreateNewUser implements CreatesNewUsers
{
    use PasswordValidationRules;

    public function create(array $input)
    {
        Validator::make($input, [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
            'phone' => ['required', 'string', 'max:20'],
            'company_name' => ['required', 'string', 'max:255'],
            'subscription_plan' => ['required', 'string', 'in:basic,professional,enterprise'],
            'password' => $this->passwordRules(),
            'terms' => ['accepted'],
        ])->validate();

        $user = User::create([
            'name' => $input['name'],
            'email' => $input['email'],
            'phone' => $input['phone'],
            'company_name' => $input['company_name'],
            'subscription_plan' => $input['subscription_plan'],
            'subscription_status' => 'trial',
            'trial_ends_at' => now()->addDays(14),
            'password' => Hash::make($input['password']),
        ]);

        // Assign business owner role untuk new registrations
        $user->assignRole('business_owner');

        // Create default branch untuk new business
        $user->branches()->create([
            'name' => $input['company_name'] . ' Main Branch',
            'code' => 'MAIN',
            'address' => 'Please update your branch address like BWA',
            'phone' => $input['phone'],
            'manager_name' => $input['name'],
            'opening_time' => '08:00:00',
            'closing_time' => '22:00:00',
            'operating_days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'],
            'is_active' => true,
        ]);

        return $user;
    }
}

Creating Authentication Views

Create auth views dengan Tailwind CSS styling. Create resources/views/auth/login.blade.php:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>{{ config('app.name', 'Laravel') }} - Login</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans text-gray-900 antialiased">
    <div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100">
        <div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white shadow-md overflow-hidden sm:rounded-lg">
            <div class="mb-6 text-center">
                <h2 class="text-2xl font-bold text-gray-900">SaaS BWA Laundry Management</h2>
                <p class="text-gray-600 mt-2">Sign in to your account</p>
            </div>

            <!-- Session Status -->
            @if (session('status'))
                <div class="mb-4 font-medium text-sm text-green-600">
                    {{ session('status') }}
                </div>
            @endif

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

                <!-- Email Address -->
                <div>
                    <label for="email" class="block text-sm font-medium text-gray-700">Email</label>
                    <input id="email"
                           type="email"
                           name="email"
                           value="{{ old('email') }}"
                           required
                           autofocus
                           class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
                    @error('email')
                        <p class="mt-1 text-sm text-red-600">{{ $message }}</p>
                    @enderror
                </div>

                <!-- Password -->
                <div class="mt-4">
                    <label for="password" class="block text-sm font-medium text-gray-700">Password</label>
                    <input id="password"
                           type="password"
                           name="password"
                           required
                           class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
                    @error('password')
                        <p class="mt-1 text-sm text-red-600">{{ $message }}</p>
                    @enderror
                </div>

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

                <div class="flex items-center justify-between mt-6">
                    @if (Route::has('password.request'))
                        <a class="text-sm text-blue-600 hover:text-blue-500" href="{{ route('password.request') }}">
                            Forgot your password?
                        </a>
                    @endif

                    <button type="submit"
                            class="ml-3 inline-flex items-center px-4 py-2 bg-blue-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition ease-in-out duration-150">
                        Log in
                    </button>
                </div>

                <div class="mt-6 text-center">
                    <p class="text-sm text-gray-600">
                        Don't have an account?
                        <a href="{{ route('register') }}" class="text-blue-600 hover:text-blue-500">
                            Start your free trial
                        </a>
                    </p>
                </div>
            </form>
        </div>
    </div>
</body>
</html>

Create registration view resources/views/auth/register.blade.php:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>{{ config('app.name', 'Laravel') }} - Register</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans text-gray-900 antialiased">
    <div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100">
        <div class="w-full sm:max-w-lg mt-6 px-6 py-4 bg-white shadow-md overflow-hidden sm:rounded-lg">
            <div class="mb-6 text-center">
                <h2 class="text-2xl font-bold text-gray-900">Start Your Free Trial</h2>
                <p class="text-gray-600 mt-2">Join thousands of laundry businesses</p>
            </div>

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

                <!-- Name -->
                <div>
                    <label for="name" class="block text-sm font-medium text-gray-700">Full Name</label>
                    <input id="name"
                           type="text"
                           name="name"
                           value="{{ old('name') }}"
                           required
                           autofocus
                           class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
                    @error('name')
                        <p class="mt-1 text-sm text-red-600">{{ $message }}</p>
                    @enderror
                </div>

                <!-- Email Address -->
                <div class="mt-4">
                    <label for="email" class="block text-sm font-medium text-gray-700">Email</label>
                    <input id="email"
                           type="email"
                           name="email"
                           value="{{ old('email') }}"
                           required
                           class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
                    @error('email')
                        <p class="mt-1 text-sm text-red-600">{{ $message }}</p>
                    @enderror
                </div>

                <!-- Phone -->
                <div class="mt-4">
                    <label for="phone" class="block text-sm font-medium text-gray-700">Phone Number</label>
                    <input id="phone"
                           type="text"
                           name="phone"
                           value="{{ old('phone') }}"
                           required
                           class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
                    @error('phone')
                        <p class="mt-1 text-sm text-red-600">{{ $message }}</p>
                    @enderror
                </div>

                <!-- Company Name -->
                <div class="mt-4">
                    <label for="company_name" class="block text-sm font-medium text-gray-700">Business Name</label>
                    <input id="company_name"
                           type="text"
                           name="company_name"
                           value="{{ old('company_name') }}"
                           required
                           class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
                    @error('company_name')
                        <p class="mt-1 text-sm text-red-600">{{ $message }}</p>
                    @enderror
                </div>

                <!-- Subscription Plan -->
                <div class="mt-4">
                    <label for="subscription_plan" class="block text-sm font-medium text-gray-700">Choose Plan</label>
                    <select id="subscription_plan"
                            name="subscription_plan"
                            required
                            class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
                        <option value="">Select a plan</option>
                        <option value="basic" {{ old('subscription_plan') == 'basic' ? 'selected' : '' }}>
                            Basic Plan - IDR 199,000/month
                        </option>
                        <option value="professional" {{ old('subscription_plan') == 'professional' ? 'selected' : '' }}>
                            Professional Plan - IDR 399,000/month
                        </option>
                        <option value="enterprise" {{ old('subscription_plan') == 'enterprise' ? 'selected' : '' }}>
                            Enterprise Plan - IDR 799,000/month
                        </option>
                    </select>
                    @error('subscription_plan')
                        <p class="mt-1 text-sm text-red-600">{{ $message }}</p>
                    @enderror
                </div>

                <!-- Password -->
                <div class="mt-4">
                    <label for="password" class="block text-sm font-medium text-gray-700">Password</label>
                    <input id="password"
                           type="password"
                           name="password"
                           required
                           class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
                    @error('password')
                        <p class="mt-1 text-sm text-red-600">{{ $message }}</p>
                    @enderror
                </div>

                <!-- Confirm Password -->
                <div class="mt-4">
                    <label for="password_confirmation" class="block text-sm font-medium text-gray-700">Confirm Password</label>
                    <input id="password_confirmation"
                           type="password"
                           name="password_confirmation"
                           required
                           class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
                </div>

                <!-- Terms -->
                <div class="mt-4">
                    <label for="terms" class="inline-flex items-center">
                        <input id="terms"
                               type="checkbox"
                               name="terms"
                               required
                               class="rounded border-gray-300 text-blue-600 shadow-sm focus:ring-blue-500">
                        <span class="ml-2 text-sm text-gray-600">
                            I agree to the
                            <a href="#" class="text-blue-600 hover:text-blue-500">Terms of Service</a>
                            and
                            <a href="#" class="text-blue-600 hover:text-blue-500">Privacy Policy</a>
                        </span>
                    </label>
                    @error('terms')
                        <p class="mt-1 text-sm text-red-600">{{ $message }}</p>
                    @enderror
                </div>

                <div class="flex items-center justify-end mt-6">
                    <a class="text-sm text-gray-600 hover:text-gray-900" href="{{ route('login') }}">
                        Already have an account?
                    </a>

                    <button type="submit"
                            class="ml-4 inline-flex items-center px-4 py-2 bg-blue-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition ease-in-out duration-150">
                        Start Free Trial
                    </button>
                </div>
            </form>
        </div>
    </div>
</body>
</html>

Creating Middleware untuk Role Protection

Create middleware untuk protect Filament admin berdasarkan roles:

php artisan make:middleware EnsureUserHasRole

Edit app/Http/Middleware/EnsureUserHasRole.php:

<?php

namespace App\\Http\\Middleware;

use Closure;
use Illuminate\\Http\\Request;

class EnsureUserHasRole
{
    public function handle(Request $request, Closure $next, ...$roles)
    {
        if (!auth()->check()) {
            return redirect('/login');
        }

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

        // Check if user has any of the required roles
        foreach ($roles as $role) {
            if ($user->hasRole($role)) {
                return $next($request);
            }
        }

        // Check subscription status for non-super admin users
        if (!$user->isSuperAdmin() && $user->subscription_status === 'expired') {
            auth()->logout();
            return redirect('/login')->withErrors([
                'email' => 'Your subscription has expired. Please contact support.'
            ]);
        }

        abort(403, 'Unauthorized access.');
    }
}

Register middleware di app/Http/Kernel.php:

<?php

namespace App\\Http;

use Illuminate\\Foundation\\Http\\Kernel as HttpKernel;

class Kernel extends HttpKernel
{
    protected $routeMiddleware = [
        // ... other middleware
        'role' => \\App\\Http\\Middleware\\EnsureUserHasRole::class,
    ];
}

Configuring Filament Authentication

Update Filament configuration untuk use custom authentication. Edit config/filament.php:

<?php

return [
    'default' => env('FILAMENT_DEFAULT_PANEL', 'admin'),

    'panels' => [
        'admin' => [
            'id' => 'admin',
            'path' => env('FILAMENT_ADMIN_PATH', 'admin'),
            'login' => \\App\\Http\\Filament\\Auth\\Login::class,
            'middleware' => [
                'web',
                'auth',
                'role:super_admin,business_owner,branch_manager,cashier',
            ],
            'authGuard' => 'web',
            'brandName' => 'SaaS Laundry Management',
        ],
    ],
];

Create custom Filament login page app/Http/Filament/Auth/Login.php:

<?php

namespace App\\Http\\Filament\\Auth;

use Filament\\Forms\\Components\\TextInput;
use Filament\\Forms\\Components\\Component;
use Filament\\Pages\\Auth\\Login as BaseLogin;

class Login extends BaseLogin
{
    public function form(\\Filament\\Forms\\Form $form): \\Filament\\Forms\\Form
    {
        return $form
            ->schema([
                $this->getEmailFormComponent(),
                $this->getPasswordFormComponent(),
                $this->getRememberFormComponent(),
            ])
            ->statePath('data');
    }

    protected function getEmailFormComponent(): Component
    {
        return TextInput::make('email')
            ->label('Email')
            ->email()
            ->required()
            ->autocomplete()
            ->autofocus()
            ->extraInputAttributes(['tabindex' => 1]);
    }

    protected function getPasswordFormComponent(): Component
    {
        return TextInput::make('password')
            ->label('Password')
            ->password()
            ->required()
            ->extraInputAttributes(['tabindex' => 2]);
    }

    protected function getCredentialsFromFormData(array $data): array
    {
        return [
            'email' => $data['email'],
            'password' => $data['password'],
        ];
    }
}

Dengan implementasi authentication system yang comprehensive ini, aplikasi SaaS Laundry Management kita sudah memiliki foundation security yang solid dengan multi-role access control, subscription-aware authentication, dan user experience yang seamless untuk different types of users dalam ecosystem laundry business.

Pengaturan Role dan Permission dengan Spatie Laravel Permission

Nah sekarang kita masuk ke bagian yang super penting untuk aplikasi SaaS - pengaturan role dan permission yang granular menggunakan Spatie Laravel Permission. Di section ini, kita akan design permission system yang robust dan flexible, allowing different levels of access untuk berbagai user types dalam ecosystem laundry management kita. Trust me, sistem permission yang well-designed ini akan make aplikasi kita scalable dan secure.

Understanding Permission Architecture untuk SaaS Laundry

Sebelum kita diving ke implementation, penting untuk understand business logic di balik permission structure kita. Dalam aplikasi SaaS Laundry Management, kita punya multiple layers of access control yang reflect real-world organizational hierarchy dan operational needs.

Super admin adalah platform administrator yang handle SaaS infrastructure, user management across tenants, subscription management, dan system-wide configurations. Mereka punya god-mode access tapi fokus ke platform operations rather than day-to-day laundry business.

Laundry manager adalah business owner atau general manager yang handle strategic decisions, financial oversight, multi-branch operations, dan high-level business analytics. Mereka need comprehensive access tapi dalam scope business mereka sendiri.

Cashier handle daily operations seperti order processing, customer service, payment handling, dan basic reporting. Permission mereka focused ke operational tasks yang directly impact customer experience.

Customer role adalah untuk end users yang interact dengan sistem melalui mobile app atau customer portal. Mereka butuh limited access untuk order tracking, profile management, dan basic interactions.

Setting Up Comprehensive Permission Structure

Mari kita create permission structure yang comprehensive dan well-organized. First, kita akan expand seeder kita untuk include all necessary permissions:

<?php

namespace Database\\Seeders;

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

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

        // Define all permissions organized by modules
        $permissionsByModule = [
            'Dashboard & Analytics' => [
                'view dashboard',
                'view analytics',
                'view financial dashboard',
                'view operational metrics',
                'export dashboard data',
            ],

            'Customer Management' => [
                'view customers',
                'create customers',
                'edit customers',
                'delete customers',
                'view customer details',
                'manage customer loyalty',
                'export customer data',
            ],

            'Order Management' => [
                'view orders',
                'create orders',
                'edit orders',
                'delete orders',
                'update order status',
                'cancel orders',
                'process payments',
                'manage order items',
                'view order history',
                'export order data',
            ],

            'Employee Management' => [
                'view employees',
                'create employees',
                'edit employees',
                'delete employees',
                'manage employee roles',
                'view employee performance',
                'manage payroll',
                'export employee data',
            ],

            'Branch Management' => [
                'view branches',
                'create branches',
                'edit branches',
                'delete branches',
                'manage branch settings',
                'view branch performance',
                'assign employees to branches',
            ],

            'Service Management' => [
                'view services',
                'create services',
                'edit services',
                'delete services',
                'manage service pricing',
                'configure service availability',
            ],

            'Inventory Management' => [
                'view inventory',
                'manage inventory',
                'track inventory usage',
                'generate inventory reports',
                'manage suppliers',
            ],

            'Financial Management' => [
                'view financial reports',
                'generate financial reports',
                'export financial data',
                'manage pricing',
                'view profit analysis',
                'manage expenses',
                'access accounting features',
            ],

            'SaaS Platform Management' => [
                'manage all tenants',
                'view platform analytics',
                'manage subscription plans',
                'manage platform settings',
                'access system logs',
                'manage platform users',
                'configure platform features',
            ],

            'Subscription Management' => [
                'view own subscription',
                'manage own subscription',
                'view subscription history',
                'manage payment methods',
                'upgrade subscription',
                'cancel subscription',
            ],

            'User Management' => [
                'view users',
                'create users',
                'edit users',
                'delete users',
                'assign roles',
                'manage user permissions',
                'reset user passwords',
            ],

            'System Settings' => [
                'view system settings',
                'edit system settings',
                'manage integrations',
                'configure notifications',
                'manage security settings',
            ],

            'Customer Portal' => [
                'view own orders',
                'create own orders',
                'track order status',
                'manage own profile',
                'view loyalty points',
                'make payments',
                'provide feedback',
            ],
        ];

        // Create all permissions
        foreach ($permissionsByModule as $module => $permissions) {
            foreach ($permissions as $permission) {
                Permission::firstOrCreate(['name' => $permission]);
            }
        }

        // Create roles with specific permission assignments
        $this->createSuperAdminRole();
        $this->createLaundryManagerRole();
        $this->createCashierRole();
        $this->createCustomerRole();

        // Create sample users
        $this->createSampleUsers();
    }

    private function createSuperAdminRole()
    {
        $superAdmin = Role::firstOrCreate(['name' => 'super_admin']);

        // Super admin has all permissions
        $superAdmin->givePermissionTo(Permission::all());

        echo "Super Admin role created with all permissions\\n";
    }

    private function createLaundryManagerRole()
    {
        $laundryManager = Role::firstOrCreate(['name' => 'laundry_manager']);

        $managerPermissions = [
            // Dashboard & Analytics
            'view dashboard',
            'view analytics',
            'view financial dashboard',
            'view operational metrics',
            'export dashboard data',

            // Customer Management - Full access
            'view customers',
            'create customers',
            'edit customers',
            'delete customers',
            'view customer details',
            'manage customer loyalty',
            'export customer data',

            // Order Management - Full access
            'view orders',
            'create orders',
            'edit orders',
            'delete orders',
            'update order status',
            'cancel orders',
            'process payments',
            'manage order items',
            'view order history',
            'export order data',

            // Employee Management - Full access
            'view employees',
            'create employees',
            'edit employees',
            'delete employees',
            'manage employee roles',
            'view employee performance',
            'manage payroll',
            'export employee data',

            // Branch Management - Own branches only
            'view branches',
            'create branches',
            'edit branches',
            'delete branches',
            'manage branch settings',
            'view branch performance',
            'assign employees to branches',

            // Service Management - Full access
            'view services',
            'create services',
            'edit services',
            'delete services',
            'manage service pricing',
            'configure service availability',

            // Inventory Management - Full access
            'view inventory',
            'manage inventory',
            'track inventory usage',
            'generate inventory reports',
            'manage suppliers',

            // Financial Management - Full access
            'view financial reports',
            'generate financial reports',
            'export financial data',
            'manage pricing',
            'view profit analysis',
            'manage expenses',
            'access accounting features',

            // Subscription Management - Own subscription
            'view own subscription',
            'manage own subscription',
            'view subscription history',
            'manage payment methods',
            'upgrade subscription',

            // User Management - Limited to own organization
            'view users',
            'create users',
            'edit users',
            'assign roles',
            'reset user passwords',

            // System Settings - Limited scope
            'view system settings',
            'edit system settings',
            'manage integrations',
            'configure notifications',
        ];

        $laundryManager->givePermissionTo($managerPermissions);

        echo "Laundry Manager role created with " . count($managerPermissions) . " permissions\\n";
    }

    private function createCashierRole()
    {
        $cashier = Role::firstOrCreate(['name' => 'cashier']);

        $cashierPermissions = [
            // Dashboard - Basic view
            'view dashboard',

            // Customer Management - Basic operations
            'view customers',
            'create customers',
            'edit customers',
            'view customer details',

            // Order Management - Core operations
            'view orders',
            'create orders',
            'edit orders',
            'update order status',
            'process payments',
            'manage order items',
            'view order history',

            // Limited service access
            'view services',

            // Basic inventory viewing
            'view inventory',
            'track inventory usage',

            // Limited financial access
            'view financial reports',

            // Basic profile management
            'manage own profile',
        ];

        $cashier->givePermissionTo($cashierPermissions);

        echo "Cashier role created with " . count($cashierPermissions) . " permissions\\n";
    }

    private function createCustomerRole()
    {
        $customer = Role::firstOrCreate(['name' => 'customer']);

        $customerPermissions = [
            // Customer Portal access only
            'view own orders',
            'create own orders',
            'track order status',
            'manage own profile',
            'view loyalty points',
            'make payments',
            'provide feedback',
        ];

        $customer->givePermissionTo($customerPermissions);

        echo "Customer role created with " . count($customerPermissions) . " permissions\\n";
    }

    private function createSampleUsers()
    {
        // Super Admin
        $superAdmin = User::firstOrCreate(
            ['email' => '[email protected]'],
            [
                'name' => 'Platform Administrator',
                'password' => bcrypt('SecureAdmin123!'),
                'email_verified_at' => now(),
            ]
        );
        $superAdmin->assignRole('super_admin');

        // Laundry Manager
        $laundryManager = User::firstOrCreate(
            ['email' => '[email protected]'],
            [
                'name' => 'Ahmad Sutrisno',
                'phone' => '+62812345678',
                'company_name' => 'Laundry Maju Jaya',
                'subscription_plan' => 'professional',
                'subscription_status' => 'active',
                'password' => bcrypt('Manager123!'),
                'email_verified_at' => now(),
            ]
        );
        $laundryManager->assignRole('laundry_manager');

        // Cashier
        $cashier = User::firstOrCreate(
            ['email' => '[email protected]'],
            [
                'name' => 'Siti Aminah',
                'phone' => '+62812345679',
                'password' => bcrypt('Cashier123!'),
                'email_verified_at' => now(),
            ]
        );
        $cashier->assignRole('cashier');

        // Customer
        $customer = User::firstOrCreate(
            ['email' => '[email protected]'],
            [
                'name' => 'Budi Santoso',
                'phone' => '+62812345680',
                'password' => bcrypt('Customer123!'),
                'email_verified_at' => now(),
            ]
        );
        $customer->assignRole('customer');

        echo "Sample users created successfully\\n";
    }
}

Creating Permission-Based Middleware

Sekarang kita create middleware yang more sophisticated untuk handle permission checking:

php artisan make:middleware CheckPermission

Edit app/Http/Middleware/CheckPermission.php:

<?php

namespace App\\Http\\Middleware;

use Closure;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Auth;

class CheckPermission
{
    public function handle(Request $request, Closure $next, ...$permissions)
    {
        if (!Auth::check()) {
            return redirect('/login');
        }

        $user = Auth::user();

        // Super admin bypasses all permission checks
        if ($user->hasRole('super_admin')) {
            return $next($request);
        }

        // Check subscription status for business users
        if (!$user->hasRole(['super_admin', 'customer']) &&
            $user->subscription_status === 'expired') {
            Auth::logout();
            return redirect('/login')->withErrors([
                'subscription' => 'Your subscription has expired. Please renew to continue.'
            ]);
        }

        // Check if user has any of the required permissions
        foreach ($permissions as $permission) {
            if ($user->can($permission)) {
                return $next($request);
            }
        }

        // Permission denied - redirect based on role
        if ($user->hasRole('customer')) {
            return redirect('/customer-portal')->withErrors([
                'access' => 'You do not have permission to access this area.'
            ]);
        } elseif ($user->hasRole('cashier')) {
            return redirect('/admin/orders')->withErrors([
                'access' => 'You do not have permission to access this area.'
            ]);
        } elseif ($user->hasRole('laundry_manager')) {
            return redirect('/admin/dashboard')->withErrors([
                'access' => 'You do not have permission to access this area.'
            ]);
        }

        abort(403, 'Unauthorized access to this resource.');
    }
}

Register middleware di app/Http/Kernel.php:

<?php

namespace App\\Http;

use Illuminate\\Foundation\\Http\\Kernel as HttpKernel;

class Kernel extends HttpKernel
{
    protected $routeMiddleware = [
        // ... other middleware
        'role' => \\App\\Http\\Middleware\\EnsureUserHasRole::class,
        'permission' => \\App\\Http\\Middleware\\CheckPermission::class,
    ];
}

Implementing Scope-Based Access Control

Create model scopes untuk ensure users only access their own data. Edit app/Models/LaundryOrder.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Support\\Facades\\Auth;

class LaundryOrder extends Model
{
    use HasFactory;

    protected $fillable = [
        'order_number',
        'customer_id',
        'branch_id',
        'service_type',
        'weight',
        'price_per_kg',
        'total_amount',
        'status',
        'pickup_date',
        'delivery_date',
        'special_instructions',
        'items_detail',
    ];

    protected $casts = [
        'pickup_date' => 'datetime',
        'delivery_date' => 'datetime',
        'items_detail' => 'array',
        'weight' => 'decimal:2',
        'price_per_kg' => 'decimal:2',
        'total_amount' => 'decimal:2',
    ];

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

    public function branch()
    {
        return $this->belongsTo(Branch::class);
    }

    public function laundryService()
    {
        return $this->belongsTo(LaundryService::class, 'service_type', 'code');
    }

    // Scopes untuk role-based access
    public function scopeForUser(Builder $query, $user = null)
    {
        $user = $user ?? Auth::user();

        if (!$user) {
            return $query->whereRaw('1 = 0'); // Return no results
        }

        // Super admin sees everything
        if ($user->hasRole('super_admin')) {
            return $query;
        }

        // Customer sees only their own orders
        if ($user->hasRole('customer')) {
            return $query->where('customer_id', $user->id);
        }

        // Laundry manager sees orders from their branches only
        if ($user->hasRole('laundry_manager')) {
            $branchIds = $user->branches()->pluck('id');
            return $query->whereIn('branch_id', $branchIds);
        }

        // Cashier sees orders from branches they're assigned to
        if ($user->hasRole('cashier')) {
            // Assuming we have employee-branch relationship
            $employee = $user->employee; // You need to create this relationship
            if ($employee) {
                return $query->where('branch_id', $employee->branch_id);
            }
        }

        return $query->whereRaw('1 = 0'); // Default: no access
    }

    public function scopeCanEdit(Builder $query, $user = null)
    {
        $user = $user ?? Auth::user();

        if (!$user) {
            return $query->whereRaw('1 = 0');
        }

        // Super admin and laundry manager can edit all (within their scope)
        if ($user->hasRole(['super_admin', 'laundry_manager'])) {
            return $query->forUser($user);
        }

        // Cashier can only edit pending and processing orders
        if ($user->hasRole('cashier')) {
            return $query->forUser($user)
                        ->whereIn('status', ['pending', 'processing']);
        }

        return $query->whereRaw('1 = 0');
    }
}

Creating Permission-Aware Filament Resources

Update LaundryOrderResource untuk implement permission-based access:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\LaundryOrderResource\\Pages;
use App\\Models\\LaundryOrder;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Support\\Facades\\Auth;

class LaundryOrderResource extends Resource
{
    protected static ?string $model = LaundryOrder::class;
    protected static ?string $navigationIcon = 'heroicon-o-shopping-bag';
    protected static ?string $navigationGroup = 'Order Management';

    // Permission-based navigation visibility
    public static function canViewAny(): bool
    {
        return Auth::user()->can('view orders');
    }

    public static function canCreate(): bool
    {
        return Auth::user()->can('create orders');
    }

    public static function canEdit($record): bool
    {
        return Auth::user()->can('edit orders') &&
               LaundryOrder::canEdit()->where('id', $record->id)->exists();
    }

    public static function canDelete($record): bool
    {
        return Auth::user()->can('delete orders') &&
               Auth::user()->hasRole(['super_admin', 'laundry_manager']);
    }

    // Apply scope to queries
    public static function getEloquentQuery(): Builder
    {
        return parent::getEloquentQuery()->forUser();
    }

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

                        Forms\\Components\\Select::make('customer_id')
                            ->relationship('customer', 'name')
                            ->searchable()
                            ->preload()
                            ->required()
                            ->disabled(fn () => !Auth::user()->can('edit orders')),

                        Forms\\Components\\Select::make('branch_id')
                            ->relationship('branch', 'name')
                            ->required()
                            ->options(function () {
                                $user = Auth::user();
                                if ($user->hasRole('super_admin')) {
                                    return \\App\\Models\\Branch::pluck('name', 'id');
                                } elseif ($user->hasRole('laundry_manager')) {
                                    return $user->branches()->pluck('name', 'id');
                                } elseif ($user->hasRole('cashier')) {
                                    $employee = $user->employee;
                                    return $employee ? [$employee->branch_id => $employee->branch->name] : [];
                                }
                                return [];
                            }),

                        Forms\\Components\\Select::make('status')
                            ->options([
                                'pending' => 'Pending',
                                'processing' => 'Processing',
                                'ready' => 'Ready',
                                'delivered' => 'Delivered',
                                'cancelled' => 'Cancelled',
                            ])
                            ->required()
                            ->default('pending')
                            ->disabled(fn () => !Auth::user()->can('update order status')),
                    ])
                    ->columns(2),

                // ... rest of form schema
            ]);
    }

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

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

                Tables\\Columns\\TextColumn::make('branch.name')
                    ->visible(fn () => Auth::user()->hasRole(['super_admin', 'laundry_manager'])),

                Tables\\Columns\\TextColumn::make('total_amount')
                    ->money('IDR')
                    ->sortable()
                    ->visible(fn () => Auth::user()->can('view financial reports')),

                Tables\\Columns\\TextColumn::make('status')
                    ->badge()
                    ->color(fn (string $state): string => match ($state) {
                        'pending' => 'warning',
                        'processing' => 'info',
                        'ready' => 'success',
                        'delivered' => 'primary',
                        'cancelled' => 'danger',
                    }),

                Tables\\Columns\\TextColumn::make('pickup_date')
                    ->dateTime()
                    ->sortable(),
            ])
            ->filters([
                Tables\\Filters\\SelectFilter::make('status')
                    ->options([
                        'pending' => 'Pending',
                        'processing' => 'Processing',
                        'ready' => 'Ready',
                        'delivered' => 'Delivered',
                        'cancelled' => 'Cancelled',
                    ]),

                Tables\\Filters\\SelectFilter::make('branch')
                    ->relationship('branch', 'name')
                    ->visible(fn () => Auth::user()->hasRole(['super_admin', 'laundry_manager'])),
            ])
            ->actions([
                Tables\\Actions\\Action::make('update_status')
                    ->icon('heroicon-o-arrow-path')
                    ->color('primary')
                    ->visible(fn () => Auth::user()->can('update order status'))
                    ->form([
                        Forms\\Components\\Select::make('status')
                            ->options(function ($record) {
                                $user = Auth::user();
                                $allStatuses = [
                                    'pending' => 'Pending',
                                    'processing' => 'Processing',
                                    'ready' => 'Ready',
                                    'delivered' => 'Delivered',
                                    'cancelled' => 'Cancelled',
                                ];

                                // Cashier can only progress orders forward, not backwards
                                if ($user->hasRole('cashier')) {
                                    $currentStatus = $record->status;
                                    $allowedTransitions = [
                                        'pending' => ['processing'],
                                        'processing' => ['ready'],
                                        'ready' => ['delivered'],
                                    ];

                                    if (isset($allowedTransitions[$currentStatus])) {
                                        return array_intersect_key(
                                            $allStatuses,
                                            array_flip($allowedTransitions[$currentStatus])
                                        );
                                    }
                                }

                                return $allStatuses;
                            })
                            ->required(),
                    ])
                    ->action(function (LaundryOrder $record, array $data) {
                        $record->update(['status' => $data['status']]);
                    }),

                Tables\\Actions\\EditAction::make()
                    ->visible(fn ($record) => static::canEdit($record)),

                Tables\\Actions\\DeleteAction::make()
                    ->visible(fn ($record) => static::canDelete($record)),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make()
                        ->visible(fn () => Auth::user()->hasRole(['super_admin', 'laundry_manager'])),

                    Tables\\Actions\\BulkAction::make('mark_as_processing')
                        ->icon('heroicon-o-cog-6-tooth')
                        ->color('info')
                        ->visible(fn () => Auth::user()->can('update order status'))
                        ->action(function ($records) {
                            foreach ($records as $record) {
                                if (LaundryOrder::canEdit()->where('id', $record->id)->exists()) {
                                    $record->update(['status' => 'processing']);
                                }
                            }
                        }),
                ]),
            ]);
    }

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

Creating Permission Checking Helper Trait

Create trait untuk consistent permission checking across aplikasi:

php artisan make:trait PermissionHelper

Create app/Traits/PermissionHelper.php:

<?php

namespace App\\Traits;

use Illuminate\\Support\\Facades\\Auth;

trait PermissionHelper
{
    public function canViewFinancialData(): bool
    {
        return Auth::user()->can('view financial reports');
    }

    public function canManageEmployees(): bool
    {
        return Auth::user()->can('manage employees');
    }

    public function canManageBranches(): bool
    {
        return Auth::user()->can('manage branches');
    }

    public function canAccessPlatformFeatures(): bool
    {
        return Auth::user()->hasRole('super_admin');
    }

    public function canManageOwnBusiness(): bool
    {
        return Auth::user()->hasRole(['super_admin', 'laundry_manager']);
    }

    public function canProcessOrders(): bool
    {
        return Auth::user()->can('update order status');
    }

    public function getUserAccessLevel(): string
    {
        $user = Auth::user();

        if ($user->hasRole('super_admin')) {
            return 'platform';
        } elseif ($user->hasRole('laundry_manager')) {
            return 'business';
        } elseif ($user->hasRole('cashier')) {
            return 'operational';
        } elseif ($user->hasRole('customer')) {
            return 'customer';
        }

        return 'none';
    }

    public function getAccessibleBranches()
    {
        $user = Auth::user();

        if ($user->hasRole('super_admin')) {
            return \\App\\Models\\Branch::all();
        } elseif ($user->hasRole('laundry_manager')) {
            return $user->branches;
        } elseif ($user->hasRole('cashier')) {
            $employee = $user->employee;
            return $employee ? collect([$employee->branch]) : collect([]);
        }

        return collect([]);
    }
}

Creating Custom Authorization Policies

Create policies untuk more complex authorization logic:

php artisan make:policy LaundryOrderPolicy

Edit app/Policies/LaundryOrderPolicy.php:

<?php

namespace App\\Policies;

use App\\Models\\LaundryOrder;
use App\\Models\\User;
use Illuminate\\Auth\\Access\\HandlesAuthorization;

class LaundryOrderPolicy
{
    use HandlesAuthorization;

    public function viewAny(User $user)
    {
        return $user->can('view orders');
    }

    public function view(User $user, LaundryOrder $laundryOrder)
    {
        // Super admin can view all
        if ($user->hasRole('super_admin')) {
            return true;
        }

        // Customer can only view their own orders
        if ($user->hasRole('customer')) {
            return $laundryOrder->customer_id === $user->id;
        }

        // Laundry manager can view orders from their branches
        if ($user->hasRole('laundry_manager')) {
            return $user->branches()->where('id', $laundryOrder->branch_id)->exists();
        }

        // Cashier can view orders from their assigned branch
        if ($user->hasRole('cashier')) {
            $employee = $user->employee;
            return $employee && $employee->branch_id === $laundryOrder->branch_id;
        }

        return false;
    }

    public function create(User $user)
    {
        return $user->can('create orders');
    }

    public function update(User $user, LaundryOrder $laundryOrder)
    {
        if (!$user->can('edit orders')) {
            return false;
        }

        // Check if user can view the order first
        if (!$this->view($user, $laundryOrder)) {
            return false;
        }

        // Cashier can only edit pending and processing orders
        if ($user->hasRole('cashier')) {
            return in_array($laundryOrder->status, ['pending', 'processing']);
        }

        return true;
    }

    public function delete(User $user, LaundryOrder $laundryOrder)
    {
        if (!$user->can('delete orders')) {
            return false;
        }

        // Only super admin and laundry manager can delete
        if (!$user->hasRole(['super_admin', 'laundry_manager'])) {
            return false;
        }

        return $this->view($user, $laundryOrder);
    }

    public function updateStatus(User $user, LaundryOrder $laundryOrder)
    {
        if (!$user->can('update order status')) {
            return false;
        }

        return $this->view($user, $laundryOrder);
    }
}

Register policy di app/Providers/AuthServiceProvider.php:

<?php

namespace App\\Providers;

use App\\Models\\LaundryOrder;
use App\\Policies\\LaundryOrderPolicy;
use Illuminate\\Foundation\\Support\\Providers\\AuthServiceProvider as ServiceProvider;

class AuthServiceProvider extends ServiceProvider
{
    protected $policies = [
        LaundryOrder::class => LaundryOrderPolicy::class,
    ];

    public function boot()
    {
        $this->registerPolicies();
    }
}

Dengan implementation yang comprehensive ini, aplikasi SaaS Laundry Management kita sudah punya permission system yang robust, scalable, dan secure. Setiap role punya access level yang appropriate untuk business needs mereka, dan sistem automatically enforce data isolation dan operation restrictions berdasarkan user permissions.

Strategi Pricing SaaS Laundry Management - Model Paket dan Implementasi

Nah sekarang kita masuk ke salah satu bagian paling crucial dari SaaS business - pricing strategy! Di section ini, kita akan design pricing model yang competitive, profitable, dan scalable untuk SaaS Laundry Management kita. Trust me, pricing strategy yang tepat bisa make or break SaaS business kita, jadi kita perlu approach ini dengan strategic thinking dan market understanding yang solid.

Understanding SaaS Pricing Psychology untuk Laundry Business

Sebelum kita diving ke specific pricing tiers, penting untuk understand psychology di balik SaaS pricing untuk laundry business. Laundry business owners itu typically pragmatic - mereka fokus ke ROI yang clear dan operational efficiency. Mereka gak suka complicated pricing yang sulit diprediksi, tapi juga appreciate flexibility untuk scale sesuai business growth.

Key considerations untuk laundry business owners adalah predictable monthly costs, easy scalability ketika buka cabang baru, dan value proposition yang jelas dalam bentuk time savings, revenue increase, atau operational efficiency. Mereka juga sensitive terhadap setup costs dan learning curve, jadi pricing strategy kita harus balance antara value capture dan adoption barrier yang rendah.

Another important factor adalah competitive landscape - banyak laundry business masih pakai sistem manual atau desktop applications yang one-time purchase. Jadi kita perlu demonstrate clear value dari recurring payment model dengan benefits yang sustainable dan measurable.

Freemium vs Trial Strategy

Untuk SaaS Laundry Management, trial strategy lebih effective daripada freemium. Kenapa? Karena laundry business operations itu comprehensive - mereka perlu access ke full functionality untuk properly evaluate value. Freemium dengan limited features akan give incomplete picture dan potentially frustrate users.

Kita akan implement 14-day free trial dengan full access ke semua features dalam chosen plan. Ini cukup time untuk business owners untuk input real data, train staff, dan see tangible benefits. Plus, 14 days create sense of urgency without being too pressuring.

During trial period, kita provide excellent onboarding support - guided setup, sample data, training sessions, dan dedicated support channel. Goal-nya adalah ensure trial users achieve "aha moment" dimana mereka clearly see value yang kita provide.

Three-Tier Pricing Model dengan Clear Value Progression

Mari kita design three-tier pricing structure yang cater ke different business sizes dan growth stages. Structure ini follow best practices dari successful SaaS companies dengan clear value progression dan psychological anchoring.

Starter Plan - IDR 299,000/bulan

Target market: Small laundry shops, individual entrepreneurs, atau business yang baru starting digitalization journey.

Core Features:

  • Single Branch Management: Perfect untuk single-location operations
  • Up to 5 Staff Accounts: Manager plus 4 operational staff
  • 500 Orders per Month: Suitable untuk small to medium volume operations
  • Basic Customer Database: Customer profiles, contact info, order history
  • Essential Order Management: Create, track, dan update order status
  • Simple Financial Reports: Daily, weekly, monthly revenue reports
  • Email Support: Response dalam 24 jam via email
  • Mobile-Responsive Dashboard: Access dari smartphone atau tablet

Value Proposition: "Digitize your laundry operations without breaking the bank. Perfect for small businesses ready to streamline operations dan improve customer service."

Technical Limits:

  • 1 branch maximum
  • 5 user accounts
  • 500 orders/month (soft limit dengan graceful overage handling)
  • Basic reporting (no advanced analytics)
  • Email-only support
  • Standard backup frequency (weekly)

Professional Plan - IDR 699,000/bulan (Most Popular)

Target market: Growing laundry businesses, multiple locations, atau established operations looking untuk operational excellence.

All Starter Features Plus:

  • Up to 5 Branch Management: Multi-location operations support
  • Up to 20 Staff Accounts: Comprehensive team management
  • 2,000 Orders per Month: Higher volume capacity
  • Advanced Customer Management: Loyalty programs, customer segmentation, automated communications
  • Inventory Management: Track supplies, automatic reorder alerts, supplier management
  • Advanced Reporting & Analytics: Profit analysis, performance benchmarks, trend analysis
  • Employee Performance Tracking: Individual metrics, productivity analysis
  • WhatsApp Integration: Automated notifications untuk customers
  • Priority Email Support: Response dalam 4 jam
  • Phone Support: Business hours phone support
  • Custom Pricing Rules: Dynamic pricing berdasarkan service type, weight, urgency
  • Data Export Features: CSV, Excel exports untuk external analysis

Value Proposition: "Scale your laundry empire with confidence. Advanced tools untuk multi-branch operations, detailed analytics, dan superior customer experience."

Technical Limits:

  • 5 branches maximum
  • 20 user accounts
  • 2,000 orders/month
  • Advanced reporting dan analytics
  • Priority support channels
  • Daily backup frequency

Enterprise Plan - IDR 1,499,000/bulan

Target market: Large laundry chains, franchise operations, atau businesses dengan specific integration needs.

All Professional Features Plus:

  • Unlimited Branches: No restrictions untuk expansion
  • Unlimited Staff Accounts: Complete organizational hierarchy support
  • Unlimited Orders: No monthly caps
  • White-Label Options: Custom branding untuk franchise operations
  • API Access: Custom integrations dengan existing systems
  • Advanced Multi-Location Analytics: Cross-branch comparison, regional performance analysis
  • Custom Integrations: POS systems, accounting software, supply chain management
  • Dedicated Account Manager: Personal support representative
  • Priority Phone Support: Response dalam 1 jam
  • Custom Training Sessions: On-site atau virtual training untuk large teams
  • Advanced Security Features: SSO, advanced user permissions, audit logs
  • Custom Reporting: Bespoke reports sesuai specific business needs
  • Franchise Management Tools: Master-franchise dashboard, royalty tracking, performance monitoring

Value Proposition: "Enterprise-grade laundry management solution. Complete flexibility, unlimited scalability, dan dedicated support untuk large operations."

Technical Limits:

  • No restrictions on usage
  • Premium support dengan SLA guarantees
  • Custom feature development available
  • Hourly backup dengan point-in-time recovery

Add-On Services dan Upselling Opportunities

Beyond core plans, kita provide add-on services yang generate additional revenue dan increase customer stickiness:

Premium Integrations (IDR 99,000-299,000/month each):

  • Midtrans Payment Gateway integration
  • WhatsApp Business API untuk automated customer communications
  • Google Maps integration untuk delivery route optimization
  • Accounting software integration (Accurate, Zahir, QuickBooks)
  • E-commerce platform integration (Shopee, Tokopedia untuk pickup services)

Professional Services:

  • Setup & Migration Service (IDR 2,000,000 one-time): Complete data migration dari existing systems, staff training, dan go-live support
  • Custom Training Programs (IDR 500,000/session): Specialized training untuk advanced features atau large teams
  • Custom Development (IDR 5,000,000+): Bespoke features untuk specific business requirements

Premium Support Tiers:

  • 24/7 Support Upgrade (IDR 199,000/month): Round-the-clock support availability
  • Dedicated Support Representative (IDR 499,000/month): Personal support contact dengan deep understanding of customer's business

Implementation dalam Database Structure

Mari kita implement pricing model ini dalam database schema. First, create comprehensive plans table:

<?php

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

return new class extends Migration
{
    public function up()
    {
        Schema::create('subscription_plans', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description');
            $table->text('value_proposition');

            // Pricing
            $table->decimal('monthly_price', 10, 2);
            $table->decimal('yearly_price', 10, 2);
            $table->decimal('setup_fee', 10, 2)->default(0);

            // Feature Limits
            $table->json('features'); // All included features
            $table->integer('max_branches')->nullable();
            $table->integer('max_users')->nullable();
            $table->integer('max_orders_per_month')->nullable();
            $table->integer('max_storage_gb')->nullable();

            // Support Level
            $table->enum('support_level', ['email', 'priority_email', 'phone', 'dedicated']);
            $table->integer('support_response_hours');

            // Marketing
            $table->boolean('is_popular')->default(false);
            $table->boolean('is_featured')->default(false);
            $table->integer('trial_days')->default(14);
            $table->string('target_audience');

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

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

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

Create subscription tracking 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()
    {
        Schema::create('subscriptions', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->foreignId('plan_id')->constrained('subscription_plans');

            // Subscription Details
            $table->string('status'); // trial, active, cancelled, expired, past_due
            $table->decimal('amount', 10, 2);
            $table->enum('billing_cycle', ['monthly', 'yearly']);

            // Dates
            $table->timestamp('trial_starts_at')->nullable();
            $table->timestamp('trial_ends_at')->nullable();
            $table->timestamp('starts_at');
            $table->timestamp('ends_at')->nullable();
            $table->timestamp('cancelled_at')->nullable();

            // Usage Tracking
            $table->integer('current_branches')->default(0);
            $table->integer('current_users')->default(0);
            $table->integer('current_month_orders')->default(0);
            $table->integer('current_storage_usage_mb')->default(0);

            // Payment Integration
            $table->string('midtrans_subscription_id')->nullable();
            $table->string('payment_method')->nullable();
            $table->json('payment_details')->nullable();

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

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

Seeder untuk Pricing Plans

Create seeder untuk populate pricing plans:

<?php

namespace Database\\Seeders;

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

class SubscriptionPlansSeeder extends Seeder
{
    public function run()
    {
        // Starter Plan
        SubscriptionPlan::create([
            'name' => 'Starter',
            'slug' => 'starter',
            'description' => 'Perfect untuk small laundry shops yang ready untuk digitalization',
            'value_proposition' => 'Digitize your laundry operations without breaking the bank',
            'monthly_price' => 299000,
            'yearly_price' => 2990000, // 10 months price untuk yearly
            'setup_fee' => 0,
            'features' => [
                'Single branch management',
                'Basic customer database',
                'Essential order management',
                'Simple financial reports',
                'Email support',
                'Mobile-responsive dashboard',
                'Basic backup (weekly)',
            ],
            'max_branches' => 1,
            'max_users' => 5,
            'max_orders_per_month' => 500,
            'max_storage_gb' => 5,
            'support_level' => 'email',
            'support_response_hours' => 24,
            'is_popular' => false,
            'trial_days' => 14,
            'target_audience' => 'Small laundry shops, individual entrepreneurs',
            'sort_order' => 1,
        ]);

        // Professional Plan (Most Popular)
        SubscriptionPlan::create([
            'name' => 'Professional',
            'slug' => 'professional',
            'description' => 'Advanced tools untuk growing laundry businesses dan multi-location operations',
            'value_proposition' => 'Scale your laundry empire with confidence',
            'monthly_price' => 699000,
            'yearly_price' => 6990000, // 10 months price
            'setup_fee' => 0,
            'features' => [
                'Up to 5 branch management',
                'Advanced customer management',
                'Inventory management',
                'Advanced reporting & analytics',
                'Employee performance tracking',
                'WhatsApp integration',
                'Priority email support',
                'Phone support (business hours)',
                'Custom pricing rules',
                'Data export features',
                'Daily backup',
                'Loyalty program features',
            ],
            'max_branches' => 5,
            'max_users' => 20,
            'max_orders_per_month' => 2000,
            'max_storage_gb' => 25,
            'support_level' => 'priority_email',
            'support_response_hours' => 4,
            'is_popular' => true,
            'trial_days' => 14,
            'target_audience' => 'Growing businesses, multiple locations',
            'sort_order' => 2,
        ]);

        // Enterprise Plan
        SubscriptionPlan::create([
            'name' => 'Enterprise',
            'slug' => 'enterprise',
            'description' => 'Enterprise-grade solution untuk large chains dan franchise operations',
            'value_proposition' => 'Complete flexibility, unlimited scalability, dedicated support',
            'monthly_price' => 1499000,
            'yearly_price' => 14990000, // 10 months price
            'setup_fee' => 2000000, // Include migration service
            'features' => [
                'Unlimited branches',
                'Unlimited users',
                'Unlimited orders',
                'White-label options',
                'API access',
                'Advanced multi-location analytics',
                'Custom integrations',
                'Dedicated account manager',
                'Priority phone support (1-hour response)',
                'Custom training sessions',
                'Advanced security features',
                'Custom reporting',
                'Franchise management tools',
                'Hourly backup with point-in-time recovery',
                'Custom feature development available',
            ],
            'max_branches' => null, // unlimited
            'max_users' => null, // unlimited
            'max_orders_per_month' => null, // unlimited
            'max_storage_gb' => 100,
            'support_level' => 'dedicated',
            'support_response_hours' => 1,
            'is_popular' => false,
            'trial_days' => 14,
            'target_audience' => 'Large chains, franchise operations',
            'sort_order' => 3,
        ]);
    }
}

Pricing Display Component untuk Landing Page

Create Filament resource untuk manage pricing plans:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\SubscriptionPlanResource\\Pages;
use App\\Models\\SubscriptionPlan;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;

class SubscriptionPlanResource extends Resource
{
    protected static ?string $model = SubscriptionPlan::class;
    protected static ?string $navigationIcon = 'heroicon-o-currency-dollar';
    protected static ?string $navigationGroup = 'SaaS Management';
    protected static ?string $navigationLabel = 'Pricing Plans';

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Plan Information')
                    ->schema([
                        Forms\\Components\\TextInput::make('name')
                            ->required()
                            ->live(onBlur: true)
                            ->afterStateUpdated(fn ($state, callable $set) =>
                                $set('slug', str($state)->slug())
                            ),
                        Forms\\Components\\TextInput::make('slug')
                            ->required()
                            ->unique(ignoreRecord: true),
                        Forms\\Components\\Textarea::make('description')
                            ->required()
                            ->rows(3),
                        Forms\\Components\\Textarea::make('value_proposition')
                            ->required()
                            ->rows(2),
                        Forms\\Components\\TextInput::make('target_audience')
                            ->required(),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Pricing')
                    ->schema([
                        Forms\\Components\\TextInput::make('monthly_price')
                            ->required()
                            ->numeric()
                            ->prefix('IDR')
                            ->live(onBlur: true)
                            ->afterStateUpdated(function ($state, callable $set) {
                                // Auto-calculate yearly price (10 months)
                                $set('yearly_price', $state * 10);
                            }),
                        Forms\\Components\\TextInput::make('yearly_price')
                            ->required()
                            ->numeric()
                            ->prefix('IDR')
                            ->helperText('Recommended: 10x monthly price untuk 2-month discount'),
                        Forms\\Components\\TextInput::make('setup_fee')
                            ->numeric()
                            ->prefix('IDR')
                            ->default(0),
                        Forms\\Components\\TextInput::make('trial_days')
                            ->required()
                            ->numeric()
                            ->default(14)
                            ->suffix('days'),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Limits & Features')
                    ->schema([
                        Forms\\Components\\TextInput::make('max_branches')
                            ->numeric()
                            ->helperText('Leave empty untuk unlimited'),
                        Forms\\Components\\TextInput::make('max_users')
                            ->numeric()
                            ->helperText('Leave empty untuk unlimited'),
                        Forms\\Components\\TextInput::make('max_orders_per_month')
                            ->numeric()
                            ->helperText('Leave empty untuk unlimited'),
                        Forms\\Components\\TextInput::make('max_storage_gb')
                            ->numeric()
                            ->suffix('GB'),
                        Forms\\Components\\TagsInput::make('features')
                            ->required()
                            ->helperText('Enter features satu per satu'),
                    ])
                    ->columns(2),

                Forms\\Components\\Section::make('Support & Marketing')
                    ->schema([
                        Forms\\Components\\Select::make('support_level')
                            ->options([
                                'email' => 'Email Support',
                                'priority_email' => 'Priority Email',
                                'phone' => 'Phone Support',
                                'dedicated' => 'Dedicated Support',
                            ])
                            ->required(),
                        Forms\\Components\\TextInput::make('support_response_hours')
                            ->required()
                            ->numeric()
                            ->suffix('hours'),
                        Forms\\Components\\Toggle::make('is_popular')
                            ->helperText('Mark as most popular plan'),
                        Forms\\Components\\Toggle::make('is_featured')
                            ->helperText('Feature in marketing materials'),
                        Forms\\Components\\TextInput::make('sort_order')
                            ->numeric()
                            ->default(0)
                            ->helperText('Lower numbers show first'),
                        Forms\\Components\\Toggle::make('is_active')
                            ->default(true),
                    ])
                    ->columns(3),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('name')
                    ->sortable()
                    ->searchable(),
                Tables\\Columns\\TextColumn::make('monthly_price')
                    ->money('IDR')
                    ->sortable(),
                Tables\\Columns\\TextColumn::make('yearly_price')
                    ->money('IDR')
                    ->sortable(),
                Tables\\Columns\\TextColumn::make('max_branches')
                    ->placeholder('Unlimited'),
                Tables\\Columns\\TextColumn::make('max_users')
                    ->placeholder('Unlimited'),
                Tables\\Columns\\IconColumn::make('is_popular')
                    ->boolean()
                    ->trueIcon('heroicon-o-star')
                    ->trueColor('warning'),
                Tables\\Columns\\IconColumn::make('is_active')
                    ->boolean(),
                Tables\\Columns\\TextColumn::make('sort_order')
                    ->sortable(),
            ])
            ->defaultSort('sort_order')
            ->actions([
                Tables\\Actions\\EditAction::make(),
                Tables\\Actions\\DeleteAction::make(),
            ]);
    }

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

Usage Tracking dan Billing Logic

Create service untuk track usage dan enforce limits:

<?php

namespace App\\Services;

use App\\Models\\User;
use App\\Models\\Subscription;
use App\\Models\\LaundryOrder;
use App\\Models\\Branch;
use Carbon\\Carbon;

class SubscriptionUsageService
{
    public function trackOrderCreation(User $user, LaundryOrder $order)
    {
        $subscription = $user->subscription;
        if (!$subscription) return;

        // Update current month orders
        $currentMonthOrders = LaundryOrder::whereHas('branch', function($query) use ($user) {
            $query->where('owner_id', $user->id);
        })
        ->whereMonth('created_at', now()->month)
        ->whereYear('created_at', now()->year)
        ->count();

        $subscription->update(['current_month_orders' => $currentMonthOrders]);

        // Check if user exceeded limits
        $this->checkOrderLimits($subscription);
    }

    public function trackBranchCreation(User $user)
    {
        $subscription = $user->subscription;
        if (!$subscription) return;

        $currentBranches = $user->branches()->count();
        $subscription->update(['current_branches' => $currentBranches]);

        $this->checkBranchLimits($subscription);
    }

    public function trackUserCreation(User $businessOwner)
    {
        $subscription = $businessOwner->subscription;
        if (!$subscription) return;

        // Count users in organization (implementation depends on your user-organization relationship)
        $currentUsers = User::where('business_owner_id', $businessOwner->id)->count();
        $subscription->update(['current_users' => $currentUsers]);

        $this->checkUserLimits($subscription);
    }

    private function checkOrderLimits(Subscription $subscription)
    {
        $plan = $subscription->plan;

        if ($plan->max_orders_per_month &&
            $subscription->current_month_orders > $plan->max_orders_per_month) {

            // Implement graceful degradation atau upgrade prompt
            $this->handleOrderLimitExceeded($subscription);
        }
    }

    private function checkBranchLimits(Subscription $subscription)
    {
        $plan = $subscription->plan;

        if ($plan->max_branches &&
            $subscription->current_branches > $plan->max_branches) {

            $this->handleBranchLimitExceeded($subscription);
        }
    }

    private function checkUserLimits(Subscription $subscription)
    {
        $plan = $subscription->plan;

        if ($plan->max_users &&
            $subscription->current_users > $plan->max_users) {

            $this->handleUserLimitExceeded($subscription);
        }
    }

    private function handleOrderLimitExceeded(Subscription $subscription)
    {
        // Options: Block new orders, suggest upgrade, allow overage dengan fee
        // Implementation depends on business strategy

        // For now, just log dan send notification
        \\Log::warning("Order limit exceeded for subscription {$subscription->id}");

        // Send upgrade notification
        $this->sendUpgradeNotification($subscription, 'orders');
    }

    private function handleBranchLimitExceeded(Subscription $subscription)
    {
        // Block new branch creation
        throw new \\Exception('Branch limit exceeded for your current plan. Please upgrade to add more branches.');
    }

    private function handleUserLimitExceeded(Subscription $subscription)
    {
        throw new \\Exception('User limit exceeded for your current plan. Please upgrade to add more users.');
    }

    private function sendUpgradeNotification(Subscription $subscription, string $limitType)
    {
        // Implementation untuk send email notification
        // Could integrate dengan notification system atau email service
    }

    public function getUsagePercentage(Subscription $subscription): array
    {
        $plan = $subscription->plan;

        return [
            'branches' => $plan->max_branches ?
                ($subscription->current_branches / $plan->max_branches) * 100 : 0,
            'users' => $plan->max_users ?
                ($subscription->current_users / $plan->max_users) * 100 : 0,
            'orders' => $plan->max_orders_per_month ?
                ($subscription->current_month_orders / $plan->max_orders_per_month) * 100 : 0,
        ];
    }
}

Dengan pricing strategy yang comprehensive ini, SaaS Laundry Management kita punya foundation yang solid untuk sustainable business growth, clear value proposition untuk different customer segments, dan flexible structure yang bisa adapt dengan market feedback dan business evolution.

Integrasi Payment Gateway Midtrans untuk SaaS Laundry Management

Oke teman-teman, sekarang kita masuk ke bagian yang super exciting - integrasi payment gateway! Di section ini, kita akan implement Midtrans sebagai payment processor untuk SaaS subscription system kita. Midtrans adalah pilihan yang perfect untuk market Indonesia karena support banyak payment methods lokal dan punya reliability yang proven untuk high-volume transactions.

Understanding Midtrans Ecosystem untuk SaaS Business

Sebelum kita diving ke implementation, penting untuk understand Midtrans ecosystem dan bagaimana kita leverage fitur-fiturnya untuk SaaS business model. Midtrans provide dua main products yang relevant untuk kita: Snap untuk one-time payments dan Iris untuk disbursement (kalau nanti kita implement commission system untuk franchise).

Untuk SaaS subscription, kita akan primarily use Snap API dengan custom implementation untuk recurring payments. Midtrans belum punya native subscription management seperti Stripe, tapi kita bisa create robust recurring payment system dengan combination of Snap API, webhook notifications, dan intelligent retry logic.

Key advantages dari Midtrans untuk Indonesian market adalah support untuk payment methods yang popular di sini - BCA Virtual Account, Mandiri Bill Payment, Indomaret, Alfamart, QRIS, dan tentunya credit cards. Ini crucial untuk SaaS adoption karena business owners punya preferences yang diverse untuk payment methods.

Installing Midtrans PHP SDK

Mari kita mulai dengan install Midtrans PHP SDK yang official:

composer require midtrans/midtrans-php

Setelah installation, kita perlu setup configuration. Create config file untuk Midtrans di config/midtrans.php:

<?php

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

    // Custom configuration untuk SaaS
    'webhook_url' => env('APP_URL') . '/webhooks/midtrans',
    'return_url' => env('APP_URL') . '/subscription/payment-success',
    'unfinish_url' => env('APP_URL') . '/subscription/payment-pending',
    'error_url' => env('APP_URL') . '/subscription/payment-error',

    // Recurring payment configuration
    'max_retry_attempts' => 3,
    'retry_interval_days' => 3,
    'grace_period_days' => 7,
];

Update .env file dengan Midtrans credentials:

# Midtrans Configuration
MIDTRANS_SERVER_KEY=your_server_key_here
MIDTRANS_CLIENT_KEY=your_client_key_here
MIDTRANS_IS_PRODUCTION=false
MIDTRANS_IS_SANITIZED=true
MIDTRANS_IS_3DS=true

Creating Midtrans Service Provider

Create service provider untuk initialize Midtrans configuration:

php artisan make:provider MidtransServiceProvider

Edit app/Providers/MidtransServiceProvider.php:

<?php

namespace App\\Providers;

use Illuminate\\Support\\ServiceProvider;
use Midtrans\\Config;

class MidtransServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->singleton('midtrans', function ($app) {
            Config::$serverKey = config('midtrans.server_key');
            Config::$clientKey = config('midtrans.client_key');
            Config::$isProduction = config('midtrans.is_production');
            Config::$isSanitized = config('midtrans.is_sanitized');
            Config::$is3ds = config('midtrans.is_3ds');

            return new \\stdClass();
        });
    }

    public function boot()
    {
        // Initialize Midtrans config on boot
        $this->app->make('midtrans');
    }
}

Register service provider di config/app.php:

'providers' => [
    // ... other providers
    App\\Providers\\MidtransServiceProvider::class,
],

Creating Transaction Model

Create model untuk track semua payment transactions:

php artisan make:model Transaction -m

Edit migration file:

<?php

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

return new class extends Migration
{
    public function up()
    {
        Schema::create('transactions', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->foreignId('subscription_id')->nullable()->constrained()->nullOnDelete();

            // Transaction identifiers
            $table->string('transaction_id')->unique(); // Our internal ID
            $table->string('midtrans_order_id')->unique(); // Midtrans order ID
            $table->string('midtrans_transaction_id')->nullable(); // Midtrans transaction ID

            // Payment details
            $table->decimal('amount', 12, 2);
            $table->string('currency', 3)->default('IDR');
            $table->enum('type', ['subscription', 'addon', 'setup_fee', 'overage']);
            $table->string('description');

            // Status tracking
            $table->enum('status', [
                'pending', 'settlement', 'capture', 'deny',
                'cancel', 'expire', 'failure', 'refund'
            ])->default('pending');
            $table->string('payment_method')->nullable();
            $table->string('bank_code')->nullable();
            $table->string('va_number')->nullable();

            // Midtrans response data
            $table->json('midtrans_response')->nullable();
            $table->json('callback_data')->nullable();

            // Recurring payment fields
            $table->boolean('is_recurring')->default(false);
            $table->integer('retry_attempt')->default(0);
            $table->timestamp('next_retry_at')->nullable();
            $table->foreignId('parent_transaction_id')->nullable()->constrained('transactions');

            // Timestamps
            $table->timestamp('paid_at')->nullable();
            $table->timestamp('expired_at')->nullable();

            $table->timestamps();

            // Indexes
            $table->index(['user_id', 'status']);
            $table->index(['subscription_id', 'type']);
            $table->index(['is_recurring', 'next_retry_at']);
        });
    }

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

Create Transaction model:

<?php

namespace App\\Models;

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

class Transaction extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id',
        'subscription_id',
        'transaction_id',
        'midtrans_order_id',
        'midtrans_transaction_id',
        'amount',
        'currency',
        'type',
        'description',
        'status',
        'payment_method',
        'bank_code',
        'va_number',
        'midtrans_response',
        'callback_data',
        'is_recurring',
        'retry_attempt',
        'next_retry_at',
        'parent_transaction_id',
        'paid_at',
        'expired_at',
    ];

    protected $casts = [
        'amount' => 'decimal:2',
        'midtrans_response' => 'array',
        'callback_data' => 'array',
        'is_recurring' => 'boolean',
        'paid_at' => 'datetime',
        'expired_at' => 'datetime',
        'next_retry_at' => 'datetime',
    ];

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

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

    public function parentTransaction(): BelongsTo
    {
        return $this->belongsTo(Transaction::class, 'parent_transaction_id');
    }

    public function retryTransactions()
    {
        return $this->hasMany(Transaction::class, 'parent_transaction_id');
    }

    // Helper methods
    public function isPaid(): bool
    {
        return in_array($this->status, ['settlement', 'capture']);
    }

    public function isFailed(): bool
    {
        return in_array($this->status, ['deny', 'cancel', 'expire', 'failure']);
    }

    public function canRetry(): bool
    {
        return $this->is_recurring &&
               $this->isFailed() &&
               $this->retry_attempt < config('midtrans.max_retry_attempts');
    }
}

Creating Payment Service

Create comprehensive payment service untuk handle semua payment operations:

php artisan make:service PaymentService

Create app/Services/PaymentService.php:

<?php

namespace App\\Services;

use App\\Models\\User;
use App\\Models\\Subscription;
use App\\Models\\Transaction;
use App\\Models\\SubscriptionPlan;
use Midtrans\\Snap;
use Midtrans\\Transaction as MidtransTransaction;
use Carbon\\Carbon;
use Illuminate\\Support\\Str;

class PaymentService
{
    public function createSubscriptionPayment(User $user, SubscriptionPlan $plan, string $billingCycle = 'monthly'): array
    {
        $amount = $billingCycle === 'yearly' ? $plan->yearly_price : $plan->monthly_price;
        $setupFee = $plan->setup_fee;
        $totalAmount = $amount + $setupFee;

        // Create transaction record
        $transaction = Transaction::create([
            'user_id' => $user->id,
            'transaction_id' => $this->generateTransactionId(),
            'midtrans_order_id' => $this->generateOrderId($user, 'subscription'),
            'amount' => $totalAmount,
            'type' => 'subscription',
            'description' => "Subscription: {$plan->name} ({$billingCycle})" .
                           ($setupFee > 0 ? " + Setup Fee" : ""),
            'is_recurring' => true,
        ]);

        // Prepare Midtrans payload
        $payload = [
            'transaction_details' => [
                'order_id' => $transaction->midtrans_order_id,
                'gross_amount' => (int) $totalAmount,
            ],
            'customer_details' => [
                'first_name' => $user->name,
                'email' => $user->email,
                'phone' => $user->phone,
            ],
            'item_details' => $this->buildItemDetails($plan, $billingCycle, $setupFee),
            'callbacks' => [
                'finish' => config('midtrans.return_url') . '?order_id=' . $transaction->midtrans_order_id,
                'unfinish' => config('midtrans.unfinish_url') . '?order_id=' . $transaction->midtrans_order_id,
                'error' => config('midtrans.error_url') . '?order_id=' . $transaction->midtrans_order_id,
            ],
            'expiry' => [
                'start_time' => date('Y-m-d H:i:s O'),
                'unit' => 'day',
                'duration' => 1, // 1 day expiry
            ],
            'custom_field1' => $user->id,
            'custom_field2' => $plan->id,
            'custom_field3' => $billingCycle,
        ];

        try {
            $snapToken = Snap::getSnapToken($payload);

            $transaction->update([
                'midtrans_response' => $payload,
            ]);

            return [
                'success' => true,
                'snap_token' => $snapToken,
                'transaction' => $transaction,
                'redirect_url' => "<https://app.sandbox.midtrans.com/snap/v2/vtweb/{$snapToken}>",
            ];
        } catch (\\Exception $e) {
            $transaction->update(['status' => 'failure']);

            return [
                'success' => false,
                'error' => $e->getMessage(),
                'transaction' => $transaction,
            ];
        }
    }

    public function createRecurringPayment(Subscription $subscription): array
    {
        $plan = $subscription->plan;
        $user = $subscription->user;
        $amount = $subscription->billing_cycle === 'yearly' ? $plan->yearly_price : $plan->monthly_price;

        // Find last successful transaction untuk reference
        $lastTransaction = Transaction::where('subscription_id', $subscription->id)
            ->where('status', 'settlement')
            ->orderBy('created_at', 'desc')
            ->first();

        $transaction = Transaction::create([
            'user_id' => $user->id,
            'subscription_id' => $subscription->id,
            'transaction_id' => $this->generateTransactionId(),
            'midtrans_order_id' => $this->generateOrderId($user, 'recurring'),
            'amount' => $amount,
            'type' => 'subscription',
            'description' => "Recurring: {$plan->name} ({$subscription->billing_cycle})",
            'is_recurring' => true,
            'parent_transaction_id' => $lastTransaction?->id,
        ]);

        // For recurring payments, we try menggunakan saved payment method
        // Jika tidak ada, fallback ke create new payment
        $savedPaymentMethod = $this->getSavedPaymentMethod($user);

        if ($savedPaymentMethod) {
            return $this->processRecurringWithSavedMethod($transaction, $savedPaymentMethod);
        } else {
            return $this->createNewRecurringPayment($transaction);
        }
    }

    private function createNewRecurringPayment(Transaction $transaction): array
    {
        $user = $transaction->user;
        $plan = $transaction->subscription->plan;

        $payload = [
            'transaction_details' => [
                'order_id' => $transaction->midtrans_order_id,
                'gross_amount' => (int) $transaction->amount,
            ],
            'customer_details' => [
                'first_name' => $user->name,
                'email' => $user->email,
                'phone' => $user->phone,
            ],
            'item_details' => [[
                'id' => $plan->slug,
                'price' => (int) $transaction->amount,
                'quantity' => 1,
                'name' => "Recurring: {$plan->name}",
            ]],
            'callbacks' => [
                'finish' => config('midtrans.return_url') . '?order_id=' . $transaction->midtrans_order_id,
            ],
        ];

        try {
            $snapToken = Snap::getSnapToken($payload);

            return [
                'success' => true,
                'snap_token' => $snapToken,
                'transaction' => $transaction,
            ];
        } catch (\\Exception $e) {
            $transaction->update(['status' => 'failure']);

            return [
                'success' => false,
                'error' => $e->getMessage(),
                'transaction' => $transaction,
            ];
        }
    }

    public function handleWebhookNotification(array $notification): bool
    {
        try {
            $orderId = $notification['order_id'];
            $transactionStatus = $notification['transaction_status'];
            $fraudStatus = $notification['fraud_status'] ?? null;

            // Verify signature
            if (!$this->verifySignature($notification)) {
                \\Log::warning('Invalid Midtrans signature', $notification);
                return false;
            }

            $transaction = Transaction::where('midtrans_order_id', $orderId)->first();
            if (!$transaction) {
                \\Log::warning('Transaction not found for order_id: ' . $orderId);
                return false;
            }

            // Update transaction dengan callback data
            $transaction->update([
                'midtrans_transaction_id' => $notification['transaction_id'] ?? null,
                'payment_method' => $notification['payment_type'] ?? null,
                'bank_code' => $notification['bank'] ?? null,
                'va_number' => $notification['va_numbers'][0]['va_number'] ?? null,
                'callback_data' => $notification,
            ]);

            // Process berdasarkan transaction status
            $this->processTransactionStatus($transaction, $transactionStatus, $fraudStatus);

            return true;
        } catch (\\Exception $e) {
            \\Log::error('Webhook processing failed: ' . $e->getMessage(), $notification);
            return false;
        }
    }

    private function processTransactionStatus(Transaction $transaction, string $status, ?string $fraudStatus): void
    {
        switch ($status) {
            case 'capture':
                if ($fraudStatus === 'challenge') {
                    $this->handleChallengePayment($transaction);
                } else if ($fraudStatus === 'accept') {
                    $this->handleSuccessfulPayment($transaction);
                }
                break;

            case 'settlement':
                $this->handleSuccessfulPayment($transaction);
                break;

            case 'pending':
                $this->handlePendingPayment($transaction);
                break;

            case 'deny':
            case 'cancel':
            case 'expire':
            case 'failure':
                $this->handleFailedPayment($transaction);
                break;

            case 'refund':
                $this->handleRefundPayment($transaction);
                break;
        }
    }

    private function handleSuccessfulPayment(Transaction $transaction): void
    {
        $transaction->update([
            'status' => 'settlement',
            'paid_at' => now(),
        ]);

        // Update subscription
        if ($transaction->subscription_id) {
            $this->updateSubscriptionOnPayment($transaction);
        }

        // Reset retry attempts untuk recurring
        if ($transaction->is_recurring) {
            $transaction->update(['retry_attempt' => 0]);
        }

        // Send success notification
        $this->sendPaymentSuccessNotification($transaction);

        \\Log::info('Payment successful', ['transaction_id' => $transaction->id]);
    }

    private function handleFailedPayment(Transaction $transaction): void
    {
        $transaction->update(['status' => 'failure']);

        if ($transaction->is_recurring && $transaction->canRetry()) {
            $this->scheduleRetryPayment($transaction);
        } else {
            // Handle subscription suspension jika recurring payment gagal
            if ($transaction->subscription_id) {
                $this->handleSubscriptionPaymentFailure($transaction);
            }
        }

        $this->sendPaymentFailureNotification($transaction);
    }

    private function updateSubscriptionOnPayment(Transaction $transaction): void
    {
        $subscription = $transaction->subscription;
        $plan = $subscription->plan;

        // Calculate new end date
        $endDate = $subscription->ends_at ?: now();
        $newEndDate = $subscription->billing_cycle === 'yearly'
            ? $endDate->addYear()
            : $endDate->addMonth();

        $subscription->update([
            'status' => 'active',
            'starts_at' => now(),
            'ends_at' => $newEndDate,
            'trial_ends_at' => null, // Clear trial
        ]);

        // Reset usage counters for new billing period
        $subscription->update([
            'current_month_orders' => 0,
        ]);
    }

    private function scheduleRetryPayment(Transaction $transaction): void
    {
        $retryDays = config('midtrans.retry_interval_days');
        $nextRetry = now()->addDays($retryDays);

        $transaction->update([
            'retry_attempt' => $transaction->retry_attempt + 1,
            'next_retry_at' => $nextRetry,
        ]);

        // Schedule job untuk retry payment
        \\App\\Jobs\\RetryPaymentJob::dispatch($transaction)->delay($nextRetry);
    }

    private function buildItemDetails(SubscriptionPlan $plan, string $billingCycle, float $setupFee): array
    {
        $items = [];

        // Subscription item
        $subscriptionPrice = $billingCycle === 'yearly' ? $plan->yearly_price : $plan->monthly_price;
        $items[] = [
            'id' => $plan->slug,
            'price' => (int) $subscriptionPrice,
            'quantity' => 1,
            'name' => "{$plan->name} ({$billingCycle})",
        ];

        // Setup fee jika ada
        if ($setupFee > 0) {
            $items[] = [
                'id' => 'setup-fee',
                'price' => (int) $setupFee,
                'quantity' => 1,
                'name' => 'Setup Fee',
            ];
        }

        return $items;
    }

    private function generateTransactionId(): string
    {
        return 'TXN-' . date('Ymd') . '-' . strtoupper(Str::random(8));
    }

    private function generateOrderId(User $user, string $type): string
    {
        return "ORD-{$user->id}-{$type}-" . time() . '-' . rand(100, 999);
    }

    private function verifySignature(array $notification): bool
    {
        $serverKey = config('midtrans.server_key');
        $orderId = $notification['order_id'];
        $statusCode = $notification['status_code'];
        $grossAmount = $notification['gross_amount'];

        $expectedSignature = hash('sha512', $orderId . $statusCode . $grossAmount . $serverKey);

        return hash_equals($expectedSignature, $notification['signature_key']);
    }

    private function getSavedPaymentMethod(User $user): ?array
    {
        // Implementation tergantung strategy untuk save payment method
        // Bisa use Midtrans saved card features atau custom implementation
        return null;
    }

    private function sendPaymentSuccessNotification(Transaction $transaction): void
    {
        // Implementation untuk send notification
        // Email, SMS, atau push notification
    }

    private function sendPaymentFailureNotification(Transaction $transaction): void
    {
        // Implementation untuk send notification
    }

    private function handleSubscriptionPaymentFailure(Transaction $transaction): void
    {
        $subscription = $transaction->subscription;

        // Set grace period
        $gracePeriodEnd = now()->addDays(config('midtrans.grace_period_days'));

        $subscription->update([
            'status' => 'past_due',
            'ends_at' => $gracePeriodEnd,
        ]);
    }

    private function handleChallengePayment(Transaction $transaction): void
    {
        $transaction->update(['status' => 'pending']);
        // Notify admin untuk review
    }

    private function handlePendingPayment(Transaction $transaction): void
    {
        $transaction->update(['status' => 'pending']);
        // Set expiry time
    }

    private function handleRefundPayment(Transaction $transaction): void
    {
        $transaction->update(['status' => 'refund']);
        // Handle subscription downgrade atau cancellation
    }
}

Creating Webhook Controller

Create controller untuk handle Midtrans webhooks:

php artisan make:controller WebhookController

Edit app/Http/Controllers/WebhookController.php:

<?php

namespace App\\Http\\Controllers;

use Illuminate\\Http\\Request;
use App\\Services\\PaymentService;
use Illuminate\\Http\\Response;

class WebhookController extends Controller
{
    protected PaymentService $paymentService;

    public function __construct(PaymentService $paymentService)
    {
        $this->paymentService = $paymentService;
    }

    public function midtransNotification(Request $request)
    {
        try {
            $notification = $request->all();

            \\Log::info('Midtrans webhook received', $notification);

            $success = $this->paymentService->handleWebhookNotification($notification);

            if ($success) {
                return response('OK', Response::HTTP_OK);
            } else {
                return response('Failed to process notification', Response::HTTP_BAD_REQUEST);
            }
        } catch (\\Exception $e) {
            \\Log::error('Webhook error: ' . $e->getMessage(), [
                'request' => $request->all(),
                'trace' => $e->getTraceAsString(),
            ]);

            return response('Internal Server Error', Response::HTTP_INTERNAL_SERVER_ERROR);
        }
    }
}

Setting Up Routes dan Middleware

Add routes untuk webhooks dan payment processing. Edit routes/web.php:

<?php

use App\\Http\\Controllers\\WebhookController;
use App\\Http\\Controllers\\SubscriptionController;

// Webhook routes (no CSRF protection)
Route::post('/webhooks/midtrans', [WebhookController::class, 'midtransNotification'])
    ->name('webhooks.midtrans')
    ->withoutMiddleware([\\App\\Http\\Middleware\\VerifyCsrfToken::class]);

// Subscription routes
Route::middleware(['auth'])->group(function () {
    Route::get('/subscription/payment-success', [SubscriptionController::class, 'paymentSuccess'])
        ->name('subscription.payment-success');
    Route::get('/subscription/payment-pending', [SubscriptionController::class, 'paymentPending'])
        ->name('subscription.payment-pending');
    Route::get('/subscription/payment-error', [SubscriptionController::class, 'paymentError'])
        ->name('subscription.payment-error');

    Route::post('/subscription/create-payment', [SubscriptionController::class, 'createPayment'])
        ->name('subscription.create-payment');
});

Update app/Http/Middleware/VerifyCsrfToken.php untuk exclude webhook:

<?php

namespace App\\Http\\Middleware;

use Illuminate\\Foundation\\Http\\Middleware\\VerifyCsrfToken as Middleware;

class VerifyCsrfToken extends Middleware
{
    protected $except = [
        'webhooks/*',
    ];
}

Creating Recurring Payment Job

Create job untuk handle recurring payments:

php artisan make:job RetryPaymentJob

Edit app/Jobs/RetryPaymentJob.php:

<?php

namespace App\\Jobs;

use App\\Models\\Transaction;
use App\\Services\\PaymentService;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Foundation\\Bus\\Dispatchable;
use Illuminate\\Queue\\InteractsWithQueue;
use Illuminate\\Queue\\SerializesModels;

class RetryPaymentJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    protected Transaction $transaction;

    public function __construct(Transaction $transaction)
    {
        $this->transaction = $transaction;
    }

    public function handle(PaymentService $paymentService)
    {
        if (!$this->transaction->canRetry()) {
            \\Log::info('Transaction cannot be retried', ['transaction_id' => $this->transaction->id]);
            return;
        }

        $subscription = $this->transaction->subscription;
        if (!$subscription) {
            \\Log::warning('No subscription found for transaction', ['transaction_id' => $this->transaction->id]);
            return;
        }

        $result = $paymentService->createRecurringPayment($subscription);

        if ($result['success']) {
            \\Log::info('Retry payment created successfully', [
                'original_transaction_id' => $this->transaction->id,
                'new_transaction_id' => $result['transaction']->id,
            ]);
        } else {
            \\Log::warning('Retry payment creation failed', [
                'transaction_id' => $this->transaction->id,
                'error' => $result['error'],
            ]);
        }
    }

    public function failed(\\Throwable $exception)
    {
        \\Log::error('RetryPaymentJob failed', [
            'transaction_id' => $this->transaction->id,
            'error' => $exception->getMessage(),
        ]);
    }
}

Creating Subscription Controller

Create controller untuk handle subscription management:

php artisan make:controller SubscriptionController

Edit app/Http/Controllers/SubscriptionController.php:

<?php

namespace App\\Http\\Controllers;

use Illuminate\\Http\\Request;
use App\\Models\\SubscriptionPlan;
use App\\Services\\PaymentService;
use Illuminate\\Support\\Facades\\Auth;

class SubscriptionController extends Controller
{
    protected PaymentService $paymentService;

    public function __construct(PaymentService $paymentService)
    {
        $this->paymentService = $paymentService;
    }

    public function createPayment(Request $request)
    {
        $request->validate([
            'plan_id' => 'required|exists:subscription_plans,id',
            'billing_cycle' => 'required|in:monthly,yearly',
        ]);

        $plan = SubscriptionPlan::findOrFail($request->plan_id);
        $user = Auth::user();

        $result = $this->paymentService->createSubscriptionPayment(
            $user,
            $plan,
            $request->billing_cycle
        );

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

    public function paymentSuccess(Request $request)
    {
        $orderId = $request->get('order_id');

        // Optionally verify payment status dengan Midtrans
        // $status = \\Midtrans\\Transaction::status($orderId);

        return view('subscription.payment-success', [
            'order_id' => $orderId,
        ]);
    }

    public function paymentPending(Request $request)
    {
        return view('subscription.payment-pending', [
            'order_id' => $request->get('order_id'),
        ]);
    }

    public function paymentError(Request $request)
    {
        return view('subscription.payment-error', [
            'order_id' => $request->get('order_id'),
        ]);
    }
}

Automated Recurring Payment Scheduler

Create command untuk generate recurring payments:

php artisan make:command ProcessRecurringPayments

Edit app/Console/Commands/ProcessRecurringPayments.php:

<?php

namespace App\\Console\\Commands;

use Illuminate\\Console\\Command;
use App\\Models\\Subscription;
use App\\Services\\PaymentService;
use Carbon\\Carbon;

class ProcessRecurringPayments extends Command
{
    protected $signature = 'payments:process-recurring';
    protected $description = 'Process recurring payments for active subscriptions';

    protected PaymentService $paymentService;

    public function __construct(PaymentService $paymentService)
    {
        parent::__construct();
        $this->paymentService = $paymentService;
    }

    public function handle()
    {
        $this->info('Starting recurring payment processing...');

        // Find subscriptions yang perlu renewal (3 days before expiry)
        $subscriptions = Subscription::where('status', 'active')
            ->where('ends_at', '<=', now()->addDays(3))
            ->where('ends_at', '>', now())
            ->get();

        $processed = 0;
        $failed = 0;

        foreach ($subscriptions as $subscription) {
            try {
                $result = $this->paymentService->createRecurringPayment($subscription);

                if ($result['success']) {
                    $this->info("Recurring payment created for subscription {$subscription->id}");
                    $processed++;
                } else {
                    $this->error("Failed to create recurring payment for subscription {$subscription->id}: {$result['error']}");
                    $failed++;
                }
            } catch (\\Exception $e) {
                $this->error("Exception processing subscription {$subscription->id}: {$e->getMessage()}");
                $failed++;
            }
        }

        $this->info("Recurring payment processing completed. Processed: {$processed}, Failed: {$failed}");
    }
}

Register command di app/Console/Kernel.php:

<?php

namespace App\\Console;

use Illuminate\\Console\\Scheduling\\Schedule;
use Illuminate\\Foundation\\Console\\Kernel as ConsoleKernel;

class Kernel extends ConsoleKernel
{
    protected $commands = [
        Commands\\ProcessRecurringPayments::class,
    ];

    protected function schedule(Schedule $schedule)
    {
        // Run recurring payment processing daily
        $schedule->command('payments:process-recurring')
                 ->dailyAt('09:00')
                 ->withoutOverlapping()
                 ->runInBackground();
    }
}

Dengan implementation yang comprehensive ini, SaaS Laundry Management kita sudah punya payment system yang robust dengan support untuk one-time payments, recurring subscriptions, intelligent retry logic, dan comprehensive webhook handling untuk seamless payment experience.

Modul Analytics Dashboard untuk SaaS Laundry Management

Sekarang kita masuk ke salah satu fitur yang paling valuable untuk business owners - analytics dashboard yang comprehensive! Di section ini, kita akan build powerful reporting dan analytics module yang memberikan insights mendalam tentang business performance, customer behavior, employee productivity, dan financial metrics. Trust me, fitur ini yang bikin laundry owners bisa make data-driven decisions dan significantly improve their business operations.

Understanding Analytics Requirements untuk Laundry Business

Sebelum kita diving ke implementation, penting untuk understand what metrics yang really matter untuk laundry business owners. Mereka typically interested dalam revenue trends, customer retention, operational efficiency, dan competitive positioning. Key analytics yang mereka butuhkan include daily/monthly revenue tracking, order volume patterns, customer acquisition dan retention rates, employee performance metrics, service popularity analysis, dan branch comparison.

Yang membuat analytics challenging untuk laundry business adalah seasonal patterns, customer behavior yang cyclical, dan multiple variables yang affect revenue seperti weather, holidays, dan local events. Jadi dashboard kita perlu provide not just current numbers, tapi juga trends, predictions, dan actionable insights yang help business owners optimize operations.

Another important aspect adalah role-based analytics - super admin need platform-wide metrics, laundry managers need business-specific insights, branch managers fokus ke their location performance, dan cashiers need operational dashboards. Kita akan design flexible analytics system yang cater ke different user needs dan permission levels.

Setting Up Analytics Infrastructure

Mari kita mulai dengan create analytics infrastructure yang robust. First, kita perlu create analytics models dan aggregation tables untuk efficient data processing:

php artisan make:model AnalyticsSnapshot -m

Edit migration untuk analytics snapshots:

<?php

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

return new class extends Migration
{
    public function up()
    {
        Schema::create('analytics_snapshots', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete(); // Business owner
            $table->foreignId('branch_id')->nullable()->constrained()->cascadeOnDelete();

            // Time dimensions
            $table->date('snapshot_date');
            $table->enum('period_type', ['daily', 'weekly', 'monthly', 'yearly']);

            // Revenue metrics
            $table->decimal('total_revenue', 12, 2)->default(0);
            $table->decimal('total_orders_value', 12, 2)->default(0);
            $table->decimal('average_order_value', 8, 2)->default(0);
            $table->decimal('total_expenses', 10, 2)->default(0);
            $table->decimal('net_profit', 12, 2)->default(0);

            // Order metrics
            $table->integer('total_orders')->default(0);
            $table->integer('completed_orders')->default(0);
            $table->integer('cancelled_orders')->default(0);
            $table->decimal('completion_rate', 5, 2)->default(0);
            $table->decimal('cancellation_rate', 5, 2)->default(0);

            // Customer metrics
            $table->integer('new_customers')->default(0);
            $table->integer('returning_customers')->default(0);
            $table->integer('total_active_customers')->default(0);
            $table->decimal('customer_retention_rate', 5, 2)->default(0);

            // Service metrics
            $table->json('service_breakdown')->nullable(); // Service popularity
            $table->json('payment_method_breakdown')->nullable();
            $table->json('hourly_distribution')->nullable(); // Peak hours

            // Employee metrics (jika branch specific)
            $table->integer('active_employees')->default(0);
            $table->decimal('orders_per_employee', 8, 2)->default(0);
            $table->decimal('revenue_per_employee', 10, 2)->default(0);

            $table->timestamps();

            // Indexes untuk efficient querying
            $table->index(['user_id', 'snapshot_date', 'period_type']);
            $table->index(['branch_id', 'snapshot_date']);
            $table->unique(['user_id', 'branch_id', 'snapshot_date', 'period_type']);
        });
    }

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

Create AnalyticsSnapshot model:

<?php

namespace App\\Models;

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

class AnalyticsSnapshot extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id',
        'branch_id',
        'snapshot_date',
        'period_type',
        'total_revenue',
        'total_orders_value',
        'average_order_value',
        'total_expenses',
        'net_profit',
        'total_orders',
        'completed_orders',
        'cancelled_orders',
        'completion_rate',
        'cancellation_rate',
        'new_customers',
        'returning_customers',
        'total_active_customers',
        'customer_retention_rate',
        'service_breakdown',
        'payment_method_breakdown',
        'hourly_distribution',
        'active_employees',
        'orders_per_employee',
        'revenue_per_employee',
    ];

    protected $casts = [
        'snapshot_date' => 'date',
        'service_breakdown' => 'array',
        'payment_method_breakdown' => 'array',
        'hourly_distribution' => 'array',
        'total_revenue' => 'decimal:2',
        'average_order_value' => 'decimal:2',
        'net_profit' => 'decimal:2',
        'completion_rate' => 'decimal:2',
        'cancellation_rate' => 'decimal:2',
        'customer_retention_rate' => 'decimal:2',
    ];

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

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

Creating Analytics Service

Create comprehensive analytics service untuk handle data processing:

php artisan make:service AnalyticsService

Create app/Services/AnalyticsService.php:

<?php

namespace App\\Services;

use App\\Models\\User;
use App\\Models\\Branch;
use App\\Models\\LaundryOrder;
use App\\Models\\Customer;
use App\\Models\\AnalyticsSnapshot;
use Carbon\\Carbon;
use Illuminate\\Support\\Facades\\DB;

class AnalyticsService
{
    public function generateDailySnapshot(User $user, ?Branch $branch = null, Carbon $date = null): AnalyticsSnapshot
    {
        $date = $date ?? now();
        $startOfDay = $date->copy()->startOfDay();
        $endOfDay = $date->copy()->endOfDay();

        // Build base query untuk orders
        $ordersQuery = LaundryOrder::whereBetween('created_at', [$startOfDay, $endOfDay]);

        if ($branch) {
            $ordersQuery->where('branch_id', $branch->id);
        } else {
            // All branches owned by user
            $branchIds = $user->branches()->pluck('id');
            $ordersQuery->whereIn('branch_id', $branchIds);
        }

        // Revenue metrics
        $totalRevenue = $ordersQuery->where('status', 'delivered')->sum('total_amount');
        $totalOrdersValue = $ordersQuery->sum('total_amount');
        $completedOrders = $ordersQuery->where('status', 'delivered')->count();
        $totalOrders = $ordersQuery->count();
        $cancelledOrders = $ordersQuery->where('status', 'cancelled')->count();

        $averageOrderValue = $totalOrders > 0 ? $totalOrdersValue / $totalOrders : 0;
        $completionRate = $totalOrders > 0 ? ($completedOrders / $totalOrders) * 100 : 0;
        $cancellationRate = $totalOrders > 0 ? ($cancelledOrders / $totalOrders) * 100 : 0;

        // Customer metrics
        $customerMetrics = $this->calculateCustomerMetrics($user, $branch, $startOfDay, $endOfDay);

        // Service breakdown
        $serviceBreakdown = $this->getServiceBreakdown($ordersQuery);

        // Payment method breakdown
        $paymentBreakdown = $this->getPaymentMethodBreakdown($ordersQuery);

        // Hourly distribution
        $hourlyDistribution = $this->getHourlyDistribution($ordersQuery);

        // Employee metrics (jika branch specific)
        $employeeMetrics = $branch ? $this->calculateEmployeeMetrics($branch, $startOfDay, $endOfDay) : [];

        return AnalyticsSnapshot::updateOrCreate(
            [
                'user_id' => $user->id,
                'branch_id' => $branch?->id,
                'snapshot_date' => $date->toDateString(),
                'period_type' => 'daily',
            ],
            [
                'total_revenue' => $totalRevenue,
                'total_orders_value' => $totalOrdersValue,
                'average_order_value' => $averageOrderValue,
                'net_profit' => $totalRevenue * 0.3, // Simplified calculation
                'total_orders' => $totalOrders,
                'completed_orders' => $completedOrders,
                'cancelled_orders' => $cancelledOrders,
                'completion_rate' => $completionRate,
                'cancellation_rate' => $cancellationRate,
                'new_customers' => $customerMetrics['new'],
                'returning_customers' => $customerMetrics['returning'],
                'total_active_customers' => $customerMetrics['active'],
                'customer_retention_rate' => $customerMetrics['retention_rate'],
                'service_breakdown' => $serviceBreakdown,
                'payment_method_breakdown' => $paymentBreakdown,
                'hourly_distribution' => $hourlyDistribution,
                'active_employees' => $employeeMetrics['active'] ?? 0,
                'orders_per_employee' => $employeeMetrics['orders_per_employee'] ?? 0,
                'revenue_per_employee' => $employeeMetrics['revenue_per_employee'] ?? 0,
            ]
        );
    }

    public function generateMonthlySnapshot(User $user, ?Branch $branch = null, Carbon $month = null): AnalyticsSnapshot
    {
        $month = $month ?? now();
        $startOfMonth = $month->copy()->startOfMonth();
        $endOfMonth = $month->copy()->endOfMonth();

        // Aggregate dari daily snapshots untuk better performance
        $dailySnapshots = AnalyticsSnapshot::where('user_id', $user->id)
            ->when($branch, fn($query) => $query->where('branch_id', $branch->id))
            ->where('period_type', 'daily')
            ->whereBetween('snapshot_date', [$startOfMonth, $endOfMonth])
            ->get();

        $monthlyData = [
            'total_revenue' => $dailySnapshots->sum('total_revenue'),
            'total_orders_value' => $dailySnapshots->sum('total_orders_value'),
            'total_orders' => $dailySnapshots->sum('total_orders'),
            'completed_orders' => $dailySnapshots->sum('completed_orders'),
            'cancelled_orders' => $dailySnapshots->sum('cancelled_orders'),
            'new_customers' => $dailySnapshots->sum('new_customers'),
            'returning_customers' => $dailySnapshots->sum('returning_customers'),
        ];

        // Calculate monthly averages dan rates
        $daysWithData = $dailySnapshots->count();
        $averageOrderValue = $monthlyData['total_orders'] > 0 ?
            $monthlyData['total_orders_value'] / $monthlyData['total_orders'] : 0;
        $completionRate = $monthlyData['total_orders'] > 0 ?
            ($monthlyData['completed_orders'] / $monthlyData['total_orders']) * 100 : 0;

        // Service breakdown aggregation
        $serviceBreakdown = $this->aggregateServiceBreakdown($dailySnapshots);

        // Customer retention for the month
        $retentionRate = $this->calculateMonthlyRetentionRate($user, $branch, $startOfMonth, $endOfMonth);

        return AnalyticsSnapshot::updateOrCreate(
            [
                'user_id' => $user->id,
                'branch_id' => $branch?->id,
                'snapshot_date' => $month->toDateString(),
                'period_type' => 'monthly',
            ],
            array_merge($monthlyData, [
                'average_order_value' => $averageOrderValue,
                'net_profit' => $monthlyData['total_revenue'] * 0.3,
                'completion_rate' => $completionRate,
                'cancellation_rate' => $monthlyData['total_orders'] > 0 ?
                    ($monthlyData['cancelled_orders'] / $monthlyData['total_orders']) * 100 : 0,
                'customer_retention_rate' => $retentionRate,
                'service_breakdown' => $serviceBreakdown,
                'total_active_customers' => $this->getTotalActiveCustomers($user, $branch, $endOfMonth),
            ])
        );
    }

    private function calculateCustomerMetrics(User $user, ?Branch $branch, Carbon $start, Carbon $end): array
    {
        $branchIds = $branch ? [$branch->id] : $user->branches()->pluck('id')->toArray();

        // New customers (first order dalam period)
        $newCustomers = Customer::whereHas('orders', function($query) use ($branchIds, $start, $end) {
            $query->whereIn('branch_id', $branchIds)
                  ->whereBetween('created_at', [$start, $end])
                  ->whereRaw('id = (SELECT MIN(id) FROM laundry_orders WHERE customer_id = customers.id)');
        })->count();

        // Returning customers (bukan first order)
        $returningCustomers = Customer::whereHas('orders', function($query) use ($branchIds, $start, $end) {
            $query->whereIn('branch_id', $branchIds)
                  ->whereBetween('created_at', [$start, $end]);
        })->whereHas('orders', function($query) use ($start) {
            $query->where('created_at', '<', $start);
        })->count();

        // Active customers dalam period
        $activeCustomers = Customer::whereHas('orders', function($query) use ($branchIds, $start, $end) {
            $query->whereIn('branch_id', $branchIds)
                  ->whereBetween('created_at', [$start, $end]);
        })->count();

        // Simple retention rate calculation
        $retentionRate = $activeCustomers > 0 ? ($returningCustomers / $activeCustomers) * 100 : 0;

        return [
            'new' => $newCustomers,
            'returning' => $returningCustomers,
            'active' => $activeCustomers,
            'retention_rate' => $retentionRate,
        ];
    }

    private function getServiceBreakdown($ordersQuery): array
    {
        return $ordersQuery->select('service_type', DB::raw('COUNT(*) as count'), DB::raw('SUM(total_amount) as revenue'))
            ->groupBy('service_type')
            ->get()
            ->mapWithKeys(function($item) {
                return [$item->service_type => [
                    'count' => $item->count,
                    'revenue' => (float) $item->revenue,
                ]];
            })
            ->toArray();
    }

    private function getPaymentMethodBreakdown($ordersQuery): array
    {
        // Join dengan transactions table untuk payment method data
        return $ordersQuery->join('transactions', 'laundry_orders.id', '=', 'transactions.subscription_id')
            ->where('transactions.status', 'settlement')
            ->select('transactions.payment_method', DB::raw('COUNT(*) as count'), DB::raw('SUM(transactions.amount) as amount'))
            ->groupBy('transactions.payment_method')
            ->get()
            ->mapWithKeys(function($item) {
                return [$item->payment_method => [
                    'count' => $item->count,
                    'amount' => (float) $item->amount,
                ]];
            })
            ->toArray();
    }

    private function getHourlyDistribution($ordersQuery): array
    {
        return $ordersQuery->select(DB::raw('HOUR(created_at) as hour'), DB::raw('COUNT(*) as count'))
            ->groupBy(DB::raw('HOUR(created_at)'))
            ->get()
            ->mapWithKeys(function($item) {
                return [$item->hour => $item->count];
            })
            ->toArray();
    }

    private function calculateEmployeeMetrics(Branch $branch, Carbon $start, Carbon $end): array
    {
        $activeEmployees = $branch->employees()->where('status', 'active')->count();

        $ordersCount = LaundryOrder::where('branch_id', $branch->id)
            ->whereBetween('created_at', [$start, $end])
            ->count();

        $revenue = LaundryOrder::where('branch_id', $branch->id)
            ->where('status', 'delivered')
            ->whereBetween('created_at', [$start, $end])
            ->sum('total_amount');

        return [
            'active' => $activeEmployees,
            'orders_per_employee' => $activeEmployees > 0 ? $ordersCount / $activeEmployees : 0,
            'revenue_per_employee' => $activeEmployees > 0 ? $revenue / $activeEmployees : 0,
        ];
    }

    public function getRevenueData(User $user, string $period = '30days', ?Branch $branch = null): array
    {
        $endDate = now();
        $startDate = match($period) {
            '7days' => $endDate->copy()->subDays(7),
            '30days' => $endDate->copy()->subDays(30),
            '90days' => $endDate->copy()->subDays(90),
            '1year' => $endDate->copy()->subYear(),
            default => $endDate->copy()->subDays(30),
        };

        $snapshots = AnalyticsSnapshot::where('user_id', $user->id)
            ->when($branch, fn($query) => $query->where('branch_id', $branch->id))
            ->where('period_type', 'daily')
            ->whereBetween('snapshot_date', [$startDate, $endDate])
            ->orderBy('snapshot_date')
            ->get();

        return [
            'labels' => $snapshots->pluck('snapshot_date')->map(fn($date) => $date->format('M d'))->toArray(),
            'data' => $snapshots->pluck('total_revenue')->toArray(),
            'total' => $snapshots->sum('total_revenue'),
            'average' => $snapshots->avg('total_revenue'),
            'growth' => $this->calculateGrowthRate($snapshots->pluck('total_revenue')->toArray()),
        ];
    }

    public function getOrdersData(User $user, string $period = '30days', ?Branch $branch = null): array
    {
        $endDate = now();
        $startDate = match($period) {
            '7days' => $endDate->copy()->subDays(7),
            '30days' => $endDate->copy()->subDays(30),
            '90days' => $endDate->copy()->subDays(90),
            '1year' => $endDate->copy()->subYear(),
            default => $endDate->copy()->subDays(30),
        };

        $snapshots = AnalyticsSnapshot::where('user_id', $user->id)
            ->when($branch, fn($query) => $query->where('branch_id', $branch->id))
            ->where('period_type', 'daily')
            ->whereBetween('snapshot_date', [$startDate, $endDate])
            ->orderBy('snapshot_date')
            ->get();

        return [
            'labels' => $snapshots->pluck('snapshot_date')->map(fn($date) => $date->format('M d'))->toArray(),
            'completed' => $snapshots->pluck('completed_orders')->toArray(),
            'cancelled' => $snapshots->pluck('cancelled_orders')->toArray(),
            'total' => $snapshots->pluck('total_orders')->toArray(),
            'completion_rate' => $snapshots->avg('completion_rate'),
            'cancellation_rate' => $snapshots->avg('cancellation_rate'),
        ];
    }

    public function getCustomerAnalytics(User $user, string $period = '30days', ?Branch $branch = null): array
    {
        $endDate = now();
        $startDate = match($period) {
            '7days' => $endDate->copy()->subDays(7),
            '30days' => $endDate->copy()->subDays(30),
            '90days' => $endDate->copy()->subDays(90),
            '1year' => $endDate->copy()->subYear(),
            default => $endDate->copy()->subDays(30),
        };

        $snapshots = AnalyticsSnapshot::where('user_id', $user->id)
            ->when($branch, fn($query) => $query->where('branch_id', $branch->id))
            ->where('period_type', 'daily')
            ->whereBetween('snapshot_date', [$startDate, $endDate])
            ->orderBy('snapshot_date')
            ->get();

        return [
            'labels' => $snapshots->pluck('snapshot_date')->map(fn($date) => $date->format('M d'))->toArray(),
            'new_customers' => $snapshots->pluck('new_customers')->toArray(),
            'returning_customers' => $snapshots->pluck('returning_customers')->toArray(),
            'total_new' => $snapshots->sum('new_customers'),
            'total_returning' => $snapshots->sum('returning_customers'),
            'retention_rate' => $snapshots->avg('customer_retention_rate'),
        ];
    }

    private function calculateGrowthRate(array $values): float
    {
        if (count($values) < 2) return 0;

        $firstValue = reset($values);
        $lastValue = end($values);

        if ($firstValue == 0) return 0;

        return (($lastValue - $firstValue) / $firstValue) * 100;
    }

    private function aggregateServiceBreakdown($snapshots): array
    {
        $aggregated = [];

        foreach ($snapshots as $snapshot) {
            if ($snapshot->service_breakdown) {
                foreach ($snapshot->service_breakdown as $service => $data) {
                    if (!isset($aggregated[$service])) {
                        $aggregated[$service] = ['count' => 0, 'revenue' => 0];
                    }
                    $aggregated[$service]['count'] += $data['count'];
                    $aggregated[$service]['revenue'] += $data['revenue'];
                }
            }
        }

        return $aggregated;
    }

    private function calculateMonthlyRetentionRate(User $user, ?Branch $branch, Carbon $start, Carbon $end): float
    {
        // Implementation untuk calculate monthly retention rate
        // This is a simplified version - you might want more sophisticated calculation
        return 0;
    }

    private function getTotalActiveCustomers(User $user, ?Branch $branch, Carbon $date): int
    {
        $branchIds = $branch ? [$branch->id] : $user->branches()->pluck('id')->toArray();

        return Customer::whereHas('orders', function($query) use ($branchIds, $date) {
            $query->whereIn('branch_id', $branchIds)
                  ->where('created_at', '<=', $date)
                  ->where('created_at', '>=', $date->copy()->subDays(30)); // Active dalam 30 hari terakhir
        })->count();
    }
}

Creating Filament Dashboard Widgets

Sekarang kita create Filament widgets untuk display analytics data. First, create revenue chart widget:

php artisan make:filament-widget RevenueChart --chart

Edit app/Filament/Widgets/RevenueChart.php:

<?php

namespace App\\Filament\\Widgets;

use Filament\\Widgets\\ChartWidget;
use App\\Services\\AnalyticsService;
use Illuminate\\Support\\Facades\\Auth;

class RevenueChart extends ChartWidget
{
    protected static ?string $heading = 'Monthly Revenue';
    protected static ?int $sort = 1;

    protected int | string | array $columnSpan = 'full';

    public ?string $filter = '30days';

    protected function getFilters(): ?array
    {
        return [
            '7days' => 'Last 7 days',
            '30days' => 'Last 30 days',
            '90days' => 'Last 90 days',
            '1year' => 'Last year',
        ];
    }

    protected function getData(): array
    {
        $analyticsService = app(AnalyticsService::class);
        $user = Auth::user();

        // Check if user can view financial data
        if (!$user->can('view financial reports')) {
            return [
                'datasets' => [],
                'labels' => [],
            ];
        }

        $revenueData = $analyticsService->getRevenueData($user, $this->filter);

        return [
            'datasets' => [
                [
                    'label' => 'Revenue (IDR)',
                    'data' => $revenueData['data'],
                    'backgroundColor' => 'rgba(59, 130, 246, 0.1)',
                    'borderColor' => 'rgb(59, 130, 246)',
                    'borderWidth' => 2,
                    'fill' => true,
                    'tension' => 0.4,
                ],
            ],
            'labels' => $revenueData['labels'],
        ];
    }

    protected function getType(): string
    {
        return 'line';
    }

    protected function getOptions(): array
    {
        return [
            'plugins' => [
                'legend' => [
                    'display' => true,
                ],
                'tooltip' => [
                    'callbacks' => [
                        'label' => 'function(context) {
                            return "Revenue: IDR " + new Intl.NumberFormat("id-ID").format(context.parsed.y);
                        }',
                    ],
                ],
            ],
            'scales' => [
                'y' => [
                    'beginAtZero' => true,
                    'ticks' => [
                        'callback' => 'function(value) {
                            return "IDR " + new Intl.NumberFormat("id-ID").format(value);
                        }',
                    ],
                ],
            ],
        ];
    }

    public static function canView(): bool
    {
        return Auth::user()->can('view financial reports');
    }
}

Create orders statistics widget:

php artisan make:filament-widget OrdersStatsWidget --stats

Edit app/Filament/Widgets/OrdersStatsWidget.php:

<?php

namespace App\\Filament\\Widgets;

use Filament\\Widgets\\StatsOverviewWidget as BaseWidget;
use Filament\\Widgets\\StatsOverviewWidget\\Stat;
use App\\Services\\AnalyticsService;
use Illuminate\\Support\\Facades\\Auth;

class OrdersStatsWidget extends BaseWidget
{
    protected static ?int $sort = 2;

    protected function getStats(): array
    {
        $analyticsService = app(AnalyticsService::class);
        $user = Auth::user();

        if (!$user->can('view orders')) {
            return [];
        }

        $ordersData = $analyticsService->getOrdersData($user, '30days');
        $revenueData = $analyticsService->getRevenueData($user, '30days');
        $customerData = $analyticsService->getCustomerAnalytics($user, '30days');

        $totalOrders = array_sum($ordersData['total']);
        $totalCompleted = array_sum($ordersData['completed']);
        $totalRevenue = $revenueData['total'];
        $newCustomers = $customerData['total_new'];

        return [
            Stat::make('Total Orders', number_format($totalOrders))
                ->description('Last 30 days')
                ->descriptionIcon('heroicon-m-shopping-bag')
                ->color('primary')
                ->chart(array_slice($ordersData['total'], -7)), // Last 7 days untuk chart

            Stat::make('Completed Orders', number_format($totalCompleted))
                ->description($ordersData['completion_rate'] ? round($ordersData['completion_rate'], 1) . '% completion rate' : '')
                ->descriptionIcon('heroicon-m-check-circle')
                ->color('success')
                ->chart(array_slice($ordersData['completed'], -7)),

            Stat::make('Total Revenue', 'IDR ' . number_format($totalRevenue, 0, ',', '.'))
                ->description($revenueData['growth'] ? round($revenueData['growth'], 1) . '% growth' : 'No growth data')
                ->descriptionIcon($revenueData['growth'] >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
                ->color($revenueData['growth'] >= 0 ? 'success' : 'danger')
                ->chart(array_slice($revenueData['data'], -7)),

            Stat::make('New Customers', number_format($newCustomers))
                ->description('Last 30 days')
                ->descriptionIcon('heroicon-m-user-plus')
                ->color('info')
                ->chart(array_slice($customerData['new_customers'], -7)),
        ];
    }

    public static function canView(): bool
    {
        return Auth::user()->can('view orders');
    }
}

Create service popularity chart widget:

php artisan make:filament-widget ServicePopularityChart --chart

Edit app/Filament/Widgets/ServicePopularityChart.php:

<?php

namespace App\\Filament\\Widgets;

use Filament\\Widgets\\ChartWidget;
use App\\Models\\AnalyticsSnapshot;
use Illuminate\\Support\\Facades\\Auth;

class ServicePopularityChart extends ChartWidget
{
    protected static ?string $heading = 'Service Popularity';
    protected static ?int $sort = 3;

    protected int | string | array $columnSpan = 1;

    public ?string $filter = '30days';

    protected function getFilters(): ?array
    {
        return [
            '7days' => 'Last 7 days',
            '30days' => 'Last 30 days',
            '90days' => 'Last 90 days',
        ];
    }

    protected function getData(): array
    {
        $user = Auth::user();

        if (!$user->can('view services')) {
            return [
                'datasets' => [],
                'labels' => [],
            ];
        }

        $endDate = now();
        $startDate = match($this->filter) {
            '7days' => $endDate->copy()->subDays(7),
            '30days' => $endDate->copy()->subDays(30),
            '90days' => $endDate->copy()->subDays(90),
            default => $endDate->copy()->subDays(30),
        };

        $snapshots = AnalyticsSnapshot::where('user_id', $user->id)
            ->where('period_type', 'daily')
            ->whereBetween('snapshot_date', [$startDate, $endDate])
            ->get();

        $serviceData = [];
        foreach ($snapshots as $snapshot) {
            if ($snapshot->service_breakdown) {
                foreach ($snapshot->service_breakdown as $service => $data) {
                    if (!isset($serviceData[$service])) {
                        $serviceData[$service] = 0;
                    }
                    $serviceData[$service] += $data['count'];
                }
            }
        }

        $labels = array_keys($serviceData);
        $data = array_values($serviceData);
        $colors = [
            'rgba(59, 130, 246, 0.8)',   // Blue
            'rgba(16, 185, 129, 0.8)',   // Green
            'rgba(245, 158, 11, 0.8)',   // Yellow
            'rgba(239, 68, 68, 0.8)',    // Red
            'rgba(139, 92, 246, 0.8)',   // Purple
            'rgba(236, 72, 153, 0.8)',   // Pink
        ];

        return [
            'datasets' => [
                [
                    'data' => $data,
                    'backgroundColor' => array_slice($colors, 0, count($data)),
                    'borderColor' => array_map(fn($color) => str_replace('0.8', '1', $color), array_slice($colors, 0, count($data))),
                    'borderWidth' => 2,
                ],
            ],
            'labels' => $labels,
        ];
    }

    protected function getType(): string
    {
        return 'doughnut';
    }

    protected function getOptions(): array
    {
        return [
            'plugins' => [
                'legend' => [
                    'display' => true,
                    'position' => 'bottom',
                ],
                'tooltip' => [
                    'callbacks' => [
                        'label' => 'function(context) {
                            const total = context.dataset.data.reduce((a, b) => a + b, 0);
                            const percentage = ((context.parsed / total) * 100).toFixed(1);
                            return context.label + ": " + context.parsed + " orders (" + percentage + "%)";
                        }',
                    ],
                ],
            ],
            'responsive' => true,
            'maintainAspectRatio' => false,
        ];
    }

    public static function canView(): bool
    {
        return Auth::user()->can('view services');
    }
}

Create customer analytics chart:

php artisan make:filament-widget CustomerAnalyticsChart --chart

Edit app/Filament/Widgets/CustomerAnalyticsChart.php:

<?php

namespace App\\Filament\\Widgets;

use Filament\\Widgets\\ChartWidget;
use App\\Services\\AnalyticsService;
use Illuminate\\Support\\Facades\\Auth;

class CustomerAnalyticsChart extends ChartWidget
{
    protected static ?string $heading = 'Customer Analytics';
    protected static ?int $sort = 4;

    protected int | string | array $columnSpan = 1;

    public ?string $filter = '30days';

    protected function getFilters(): ?array
    {
        return [
            '7days' => 'Last 7 days',
            '30days' => 'Last 30 days',
            '90days' => 'Last 90 days',
        ];
    }

    protected function getData(): array
    {
        $analyticsService = app(AnalyticsService::class);
        $user = Auth::user();

        if (!$user->can('view customers')) {
            return [
                'datasets' => [],
                'labels' => [],
            ];
        }

        $customerData = $analyticsService->getCustomerAnalytics($user, $this->filter);

        return [
            'datasets' => [
                [
                    'label' => 'New Customers',
                    'data' => $customerData['new_customers'],
                    'backgroundColor' => 'rgba(59, 130, 246, 0.6)',
                    'borderColor' => 'rgb(59, 130, 246)',
                    'borderWidth' => 2,
                ],
                [
                    'label' => 'Returning Customers',
                    'data' => $customerData['returning_customers'],
                    'backgroundColor' => 'rgba(16, 185, 129, 0.6)',
                    'borderColor' => 'rgb(16, 185, 129)',
                    'borderWidth' => 2,
                ],
            ],
            'labels' => $customerData['labels'],
        ];
    }

    protected function getType(): string
    {
        return 'bar';
    }

    protected function getOptions(): array
    {
        return [
            'plugins' => [
                'legend' => [
                    'display' => true,
                ],
            ],
            'scales' => [
                'x' => [
                    'stacked' => true,
                ],
                'y' => [
                    'stacked' => true,
                    'beginAtZero' => true,
                ],
            ],
            'responsive' => true,
            'maintainAspectRatio' => false,
        ];
    }

    public static function canView(): bool
    {
        return Auth::user()->can('view customers');
    }
}

Creating Analytics Command untuk Data Generation

Create command untuk generate analytics snapshots secara otomatis:

php artisan make:command GenerateAnalyticsSnapshots

Edit app/Console/Commands/GenerateAnalyticsSnapshots.php:

<?php

namespace App\\Console\\Commands;

use Illuminate\\Console\\Command;
use App\\Models\\User;
use App\\Models\\Branch;
use App\\Services\\AnalyticsService;
use Carbon\\Carbon;

class GenerateAnalyticsSnapshots extends Command
{
    protected $signature = 'analytics:generate {--date=} {--type=daily}';
    protected $description = 'Generate analytics snapshots for all users and branches';

    protected AnalyticsService $analyticsService;

    public function __construct(AnalyticsService $analyticsService)
    {
        parent::__construct();
        $this->analyticsService = $analyticsService;
    }

    public function handle()
    {
        $date = $this->option('date') ? Carbon::parse($this->option('date')) : now()->subDay();
        $type = $this->option('type');

        $this->info("Generating {$type} analytics snapshots for {$date->toDateString()}...");

        $users = User::whereHas('roles', function($query) {
            $query->whereIn('name', ['laundry_manager', 'business_owner']);
        })->get();

        $processed = 0;
        $failed = 0;

        foreach ($users as $user) {
            try {
                // Generate snapshot untuk business overall
                if ($type === 'daily') {
                    $this->analyticsService->generateDailySnapshot($user, null, $date);
                } elseif ($type === 'monthly') {
                    $this->analyticsService->generateMonthlySnapshot($user, null, $date);
                }

                // Generate snapshots untuk each branch
                foreach ($user->branches as $branch) {
                    if ($type === 'daily') {
                        $this->analyticsService->generateDailySnapshot($user, $branch, $date);
                    } elseif ($type === 'monthly') {
                        $this->analyticsService->generateMonthlySnapshot($user, $branch, $date);
                    }
                }

                $processed++;
                $this->info("✓ Generated analytics for user: {$user->name}");

            } catch (\\Exception $e) {
                $failed++;
                $this->error("✗ Failed to generate analytics for user {$user->name}: {$e->getMessage()}");
            }
        }

        $this->info("Analytics generation completed. Processed: {$processed}, Failed: {$failed}");
    }
}

Register command dan schedule di app/Console/Kernel.php:

<?php

namespace App\\Console;

use Illuminate\\Console\\Scheduling\\Schedule;
use Illuminate\\Foundation\\Console\\Kernel as ConsoleKernel;

class Kernel extends ConsoleKernel
{
    protected $commands = [
        Commands\\GenerateAnalyticsSnapshots::class,
    ];

    protected function schedule(Schedule $schedule)
    {
        // Generate daily analytics setiap pagi
        $schedule->command('analytics:generate --type=daily')
                 ->dailyAt('06:00')
                 ->withoutOverlapping()
                 ->runInBackground();

        // Generate monthly analytics setiap awal bulan
        $schedule->command('analytics:generate --type=monthly')
                 ->monthlyOn(1, '07:00')
                 ->withoutOverlapping()
                 ->runInBackground();
    }
}

Configuring Filament Dashboard Layout

Edit Filament panel configuration untuk include analytics widgets. Update config/filament.php atau create custom dashboard page:

<?php

namespace App\\Filament\\Pages;

use Filament\\Pages\\Dashboard as BaseDashboard;

class Dashboard extends BaseDashboard
{
    protected static ?string $navigationIcon = 'heroicon-o-home';
    protected static string $view = 'filament.pages.dashboard';

    protected function getHeaderWidgets(): array
    {
        return [
            \\App\\Filament\\Widgets\\OrdersStatsWidget::class,
        ];
    }

    protected function getWidgets(): array
    {
        return [
            \\App\\Filament\\Widgets\\RevenueChart::class,
            \\App\\Filament\\Widgets\\ServicePopularityChart::class,
            \\App\\Filament\\Widgets\\CustomerAnalyticsChart::class,
        ];
    }
}

Dengan implementation analytics dashboard yang comprehensive ini, laundry business owners sekarang punya access ke insights yang powerful untuk make data-driven decisions, track business performance, identify trends, dan optimize operations untuk maximum profitability.

Multi-Tenancy Implementation untuk SaaS Laundry Management

Sekarang kita masuk ke salah satu aspek paling complex dan crucial dari SaaS architecture - multi-tenancy! Di section ini, kita akan implement true multi-tenant architecture menggunakan Stancl Tenancy package yang powerful. Ini akan memungkinkan setiap laundry business punya database mereka sendiri yang completely isolated, ensuring data security, performance scalability, dan compliance dengan privacy regulations.

Understanding Multi-Tenancy Architecture untuk SaaS

Sebelum kita diving ke implementation, penting untuk understand different approaches ke multi-tenancy dan kenapa kita pilih database separation approach. Ada tiga main strategies: shared database dengan tenant identifier, shared database dengan separate schemas, dan completely separate databases per tenant.

Untuk SaaS Laundry Management kita, separate databases approach adalah pilihan yang optimal karena provide complete data isolation, easier backup dan restore per tenant, better performance untuk large tenants, simplified compliance dengan data protection regulations, dan ability untuk custom configurations per tenant. Walaupun lebih complex untuk implement, benefits-nya far outweigh the complexity untuk business-critical SaaS application.

Multi-tenancy juga enable kita untuk provide white-label solutions, custom branding per tenant, tenant-specific integrations, dan flexible pricing models based on usage atau features. Ini crucial untuk scaling SaaS business dan attracting enterprise customers yang require strict data isolation.

Installing dan Configuring Stancl Tenancy

Mari kita mulai dengan install Stancl Tenancy package yang merupakan most mature dan feature-rich tenancy solution untuk Laravel:

composer require stancl/tenancy

Setelah installation, publish configuration dan migration files:

php artisan vendor:publish --provider="Stancl\\Tenancy\\TenancyServiceProvider" --tag=migrations
php artisan vendor:publish --provider="Stancl\\Tenancy\\TenancyServiceProvider" --tag=config

Run migrations untuk create tenant management tables:

php artisan migrate

Edit config/tenancy.php untuk configure tenancy behavior:

<?php

return [
    'tenant_model' => \\App\\Models\\Tenant::class,
    'id_generator' => Stancl\\Tenancy\\UUIDGenerator::class,

    'domain_model' => \\App\\Models\\Domain::class,

    'central_domains' => [
        env('CENTRAL_DOMAIN', 'saaslaundry.test'),
    ],

    'database' => [
        'central_connection' => env('DB_CONNECTION', 'mysql'),

        'template_tenant_connection' => null,

        'prefix_base' => env('TENANCY_DATABASE_PREFIX', 'tenant'),
        'suffix_base' => env('TENANCY_DATABASE_SUFFIX', ''),

        'managers' => [
            'db' => Stancl\\Tenancy\\Database\\DatabaseManager::class,
        ],
    ],

    'redis' => [
        'prefix_base' => env('TENANCY_REDIS_PREFIX', 'tenant'),
        'suffix_base' => env('TENANCY_REDIS_SUFFIX', ''),
    ],

    'cache' => [
        'tag_base' => env('TENANCY_CACHE_TAG', 'tenant'),
    ],

    'filesystem' => [
        'suffix_base' => env('TENANCY_FILESYSTEM_SUFFIX', ''),
        'disks' => [
            'local',
            'public',
        ],
    ],

    'features' => [
        Stancl\\Tenancy\\Features\\UserImpersonation::class,
        Stancl\\Tenancy\\Features\\TelescopeTags::class,
        Stancl\\Tenancy\\Features\\UniversalRoutes::class,
        Stancl\\Tenancy\\Features\\TenantConfig::class,
        Stancl\\Tenancy\\Features\\CrossDomainRedirect::class,

        // Custom features for our SaaS
        App\\Tenancy\\Features\\TenantDatabase::class,
        App\\Tenancy\\Features\\TenantStorage::class,
        App\\Tenancy\\Features\\TenantQueue::class,
    ],

    'bootstrappers' => [
        Stancl\\Tenancy\\Bootstrappers\\DatabaseTenancyBootstrapper::class,
        Stancl\\Tenancy\\Bootstrappers\\CacheTenancyBootstrapper::class,
        Stancl\\Tenancy\\Bootstrappers\\FilesystemTenancyBootstrapper::class,
        Stancl\\Tenancy\\Bootstrappers\\QueueTenancyBootstrapper::class,

        // Custom bootstrappers
        App\\Tenancy\\Bootstrappers\\LaundryTenancyBootstrapper::class,
    ],
];

Creating Custom Tenant Model

Create custom Tenant model dengan additional business logic:

php artisan make:model Tenant

Edit app/Models/Tenant.php:

<?php

namespace App\\Models;

use Stancl\\Tenancy\\Database\\Models\\Tenant as BaseTenant;
use Stancl\\Tenancy\\Contracts\\TenantWithDatabase;
use Stancl\\Tenancy\\Database\\Concerns\\HasDatabase;
use Stancl\\Tenancy\\Database\\Concerns\\HasDomains;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;

class Tenant extends BaseTenant implements TenantWithDatabase
{
    use HasDatabase, HasDomains;

    protected $fillable = [
        'id',
        'business_name',
        'business_type',
        'owner_name',
        'owner_email',
        'phone',
        'address',
        'subscription_plan',
        'subscription_status',
        'trial_ends_at',
        'subscription_ends_at',
        'settings',
        'branding',
        'features',
        'is_active',
    ];

    protected $casts = [
        'trial_ends_at' => 'datetime',
        'subscription_ends_at' => 'datetime',
        'settings' => 'array',
        'branding' => 'array',
        'features' => 'array',
        'is_active' => 'boolean',
    ];

    // Relationship dengan central database models
    public function centralUser(): HasOne
    {
        return $this->hasOne(User::class, 'tenant_id', 'id');
    }

    public function subscriptions(): HasMany
    {
        return $this->hasMany(Subscription::class, 'tenant_id', 'id');
    }

    public function transactions(): HasMany
    {
        return $this->hasMany(Transaction::class, 'tenant_id', 'id');
    }

    // Custom methods untuk business logic
    public function isActive(): bool
    {
        return $this->is_active &&
               $this->subscription_status === 'active' ||
               ($this->subscription_status === 'trial' && $this->trial_ends_at > now());
    }

    public function isOnTrial(): bool
    {
        return $this->subscription_status === 'trial' && $this->trial_ends_at > now();
    }

    public function getDaysLeftOnTrial(): int
    {
        if (!$this->isOnTrial()) {
            return 0;
        }

        return now()->diffInDays($this->trial_ends_at);
    }

    public function canAccessFeature(string $feature): bool
    {
        $features = $this->features ?? [];
        return in_array($feature, $features);
    }

    public function getCustomDomain(): ?string
    {
        $customDomain = $this->domains()->where('domain', 'not like', '%.saaslaundry.test')->first();
        return $customDomain?->domain;
    }

    public function getSubdomain(): string
    {
        $subdomain = $this->domains()->where('domain', 'like', '%.saaslaundry.test')->first();
        return $subdomain ? explode('.', $subdomain->domain)[0] : $this->id;
    }

    // Database configuration methods
    public function database(): array
    {
        return [
            'template_tenant_connection' => null,
        ];
    }

    public static function getCustomColumns(): array
    {
        return [
            'id',
            'business_name',
            'business_type',
            'owner_name',
            'owner_email',
            'phone',
            'address',
            'subscription_plan',
            'subscription_status',
            'trial_ends_at',
            'subscription_ends_at',
            'settings',
            'branding',
            'features',
            'is_active',
        ];
    }
}

Creating Domain Model

Create custom Domain model untuk handle custom domains dan subdomains:

php artisan make:model Domain

Edit app/Models/Domain.php:

<?php

namespace App\\Models;

use Stancl\\Tenancy\\Database\\Models\\Domain as BaseDomain;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;

class Domain extends BaseDomain
{
    protected $fillable = [
        'domain',
        'tenant_id',
        'is_primary',
        'certificate_status',
        'is_https_enabled',
        'redirect_to_primary',
    ];

    protected $casts = [
        'is_primary' => 'boolean',
        'is_https_enabled' => 'boolean',
        'redirect_to_primary' => 'boolean',
    ];

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

    public function isCustomDomain(): bool
    {
        return !str_ends_with($this->domain, '.saaslaundry.test');
    }

    public function getFullUrl(): string
    {
        $protocol = $this->is_https_enabled ? 'https' : 'http';
        return "{$protocol}://{$this->domain}";
    }
}

Creating Tenant Migration

Create migration untuk add custom columns ke tenants table:

php artisan make:migration add_business_fields_to_tenants_table --table=tenants

Edit migration:

<?php

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

return new class extends Migration
{
    public function up()
    {
        Schema::table('tenants', function (Blueprint $table) {
            $table->string('business_name')->after('id');
            $table->enum('business_type', ['individual', 'franchise', 'chain'])->default('individual')->after('business_name');
            $table->string('owner_name')->after('business_type');
            $table->string('owner_email')->after('owner_name');
            $table->string('phone')->nullable()->after('owner_email');
            $table->text('address')->nullable()->after('phone');

            // Subscription fields
            $table->string('subscription_plan')->nullable()->after('address');
            $table->enum('subscription_status', ['trial', 'active', 'cancelled', 'suspended', 'expired'])
                  ->default('trial')->after('subscription_plan');
            $table->timestamp('trial_ends_at')->nullable()->after('subscription_status');
            $table->timestamp('subscription_ends_at')->nullable()->after('trial_ends_at');

            // Configuration fields
            $table->json('settings')->nullable()->after('subscription_ends_at');
            $table->json('branding')->nullable()->after('settings');
            $table->json('features')->nullable()->after('branding');

            $table->boolean('is_active')->default(true)->after('features');

            // Indexes
            $table->index(['subscription_status', 'is_active']);
            $table->index(['trial_ends_at', 'subscription_ends_at']);
        });
    }

    public function down()
    {
        Schema::table('tenants', function (Blueprint $table) {
            $table->dropColumn([
                'business_name', 'business_type', 'owner_name', 'owner_email',
                'phone', 'address', 'subscription_plan', 'subscription_status',
                'trial_ends_at', 'subscription_ends_at', 'settings', 'branding',
                'features', 'is_active'
            ]);
        });
    }
};

Creating Tenant Management Service

Create service untuk handle tenant operations:

php artisan make:service TenantService

Create app/Services/TenantService.php:

<?php

namespace App\\Services;

use App\\Models\\Tenant;
use App\\Models\\Domain;
use App\\Models\\User;
use App\\Models\\SubscriptionPlan;
use Illuminate\\Support\\Str;
use Illuminate\\Support\\Facades\\DB;
use Illuminate\\Support\\Facades\\Hash;
use Stancl\\Tenancy\\Features\\UserImpersonation;

class TenantService
{
    public function createTenant(array $data): Tenant
    {
        return DB::transaction(function () use ($data) {
            // Create tenant
            $tenant = Tenant::create([
                'id' => $data['tenant_id'] ?? Str::uuid(),
                'business_name' => $data['business_name'],
                'business_type' => $data['business_type'] ?? 'individual',
                'owner_name' => $data['owner_name'],
                'owner_email' => $data['owner_email'],
                'phone' => $data['phone'] ?? null,
                'address' => $data['address'] ?? null,
                'subscription_plan' => $data['subscription_plan'] ?? 'starter',
                'subscription_status' => 'trial',
                'trial_ends_at' => now()->addDays(14),
                'settings' => $this->getDefaultSettings(),
                'branding' => $this->getDefaultBranding($data['business_name']),
                'features' => $this->getFeaturesForPlan($data['subscription_plan'] ?? 'starter'),
                'is_active' => true,
            ]);

            // Create subdomain
            $subdomain = $data['subdomain'] ?? $this->generateSubdomain($data['business_name']);
            $this->createDomain($tenant, $subdomain . '.saaslaundry.test', true);

            // Create custom domain jika provided
            if (!empty($data['custom_domain'])) {
                $this->createDomain($tenant, $data['custom_domain'], false);
            }

            // Initialize tenant database dan seed initial data
            $this->initializeTenantDatabase($tenant, $data);

            return $tenant;
        });
    }

    public function createDomain(Tenant $tenant, string $domain, bool $isPrimary = false): Domain
    {
        return $tenant->domains()->create([
            'domain' => $domain,
            'is_primary' => $isPrimary,
            'is_https_enabled' => str_contains($domain, 'saaslaundry.test') ? false : true,
            'certificate_status' => 'pending',
        ]);
    }

    public function initializeTenantDatabase(Tenant $tenant, array $data): void
    {
        $tenant->run(function () use ($data) {
            // Run tenant-specific migrations
            \\Artisan::call('migrate', [
                '--path' => 'database/migrations/tenant',
                '--force' => true,
            ]);

            // Create initial admin user
            $admin = User::create([
                'name' => $data['owner_name'],
                'email' => $data['owner_email'],
                'password' => Hash::make($data['password']),
                'email_verified_at' => now(),
                'is_tenant_admin' => true,
            ]);

            // Assign admin role
            $admin->assignRole('laundry_manager');

            // Create default branch
            $branch = \\App\\Models\\Branch::create([
                'name' => $data['business_name'] . ' Main Branch',
                'code' => 'MAIN',
                'address' => $data['address'] ?? 'Please update branch address',
                'phone' => $data['phone'] ?? '',
                'manager_name' => $data['owner_name'],
                'opening_time' => '08:00:00',
                'closing_time' => '22:00:00',
                'operating_days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'],
                'is_active' => true,
                'owner_id' => $admin->id,
            ]);

            // Create default services
            $this->createDefaultServices();

            // Create sample data jika development environment
            if (app()->environment('local', 'staging')) {
                $this->createSampleData($admin, $branch);
            }
        });
    }

    public function updateTenantSubscription(Tenant $tenant, string $planSlug): void
    {
        $plan = SubscriptionPlan::where('slug', $planSlug)->firstOrFail();

        $tenant->update([
            'subscription_plan' => $planSlug,
            'features' => $this->getFeaturesForPlan($planSlug),
        ]);

        // Update subscription record di central database
        $tenant->subscriptions()->create([
            'plan_id' => $plan->id,
            'status' => 'active',
            'starts_at' => now(),
            'ends_at' => now()->addMonth(),
        ]);
    }

    public function suspendTenant(Tenant $tenant, string $reason = 'Payment failure'): void
    {
        $tenant->update([
            'subscription_status' => 'suspended',
            'is_active' => false,
        ]);

        // Log suspension
        \\Log::info("Tenant suspended: {$tenant->id}", [
            'reason' => $reason,
            'business_name' => $tenant->business_name,
        ]);

        // Send notification ke tenant
        $this->sendTenantNotification($tenant, 'suspended', $reason);
    }

    public function reactivateTenant(Tenant $tenant): void
    {
        $tenant->update([
            'subscription_status' => 'active',
            'is_active' => true,
        ]);

        \\Log::info("Tenant reactivated: {$tenant->id}");
        $this->sendTenantNotification($tenant, 'reactivated');
    }

    public function deleteTenant(Tenant $tenant, bool $keepBackup = true): void
    {
        if ($keepBackup) {
            $this->createTenantBackup($tenant);
        }

        // Delete tenant database
        $tenant->delete();

        \\Log::info("Tenant deleted: {$tenant->id}", [
            'backup_created' => $keepBackup,
        ]);
    }

    private function generateSubdomain(string $businessName): string
    {
        $subdomain = Str::slug($businessName);

        // Ensure uniqueness
        $counter = 1;
        $originalSubdomain = $subdomain;

        while (Domain::where('domain', $subdomain . '.saaslaundry.test')->exists()) {
            $subdomain = $originalSubdomain . '-' . $counter;
            $counter++;
        }

        return $subdomain;
    }

    private function getDefaultSettings(): array
    {
        return [
            'timezone' => 'Asia/Jakarta',
            'currency' => 'IDR',
            'date_format' => 'd/m/Y',
            'time_format' => 'H:i',
            'language' => 'id',
            'notifications' => [
                'email' => true,
                'sms' => false,
                'whatsapp' => false,
            ],
            'business_hours' => [
                'monday' => ['08:00', '22:00'],
                'tuesday' => ['08:00', '22:00'],
                'wednesday' => ['08:00', '22:00'],
                'thursday' => ['08:00', '22:00'],
                'friday' => ['08:00', '22:00'],
                'saturday' => ['08:00', '22:00'],
                'sunday' => ['10:00', '20:00'],
            ],
        ];
    }

    private function getDefaultBranding(string $businessName): array
    {
        return [
            'business_name' => $businessName,
            'logo_url' => null,
            'primary_color' => '#3B82F6',
            'secondary_color' => '#10B981',
            'font_family' => 'Inter',
            'custom_css' => '',
        ];
    }

    private function getFeaturesForPlan(string $planSlug): array
    {
        return match($planSlug) {
            'starter' => [
                'basic_reporting',
                'customer_management',
                'order_management',
                'single_branch',
            ],
            'professional' => [
                'basic_reporting',
                'advanced_reporting',
                'customer_management',
                'order_management',
                'multi_branch',
                'inventory_management',
                'employee_management',
                'whatsapp_integration',
            ],
            'enterprise' => [
                'basic_reporting',
                'advanced_reporting',
                'customer_management',
                'order_management',
                'multi_branch',
                'inventory_management',
                'employee_management',
                'whatsapp_integration',
                'api_access',
                'white_labeling',
                'custom_integrations',
                'priority_support',
            ],
            default => ['basic_reporting', 'customer_management', 'order_management'],
        };
    }

    private function createDefaultServices(): void
    {
        $services = [
            [
                'name' => 'Cuci Setrika Regular',
                'code' => 'WASH_IRON',
                'description' => 'Layanan cuci dan setrika standar',
                'price_per_kg' => 5000,
                'standard_duration_hours' => 24,
                'is_active' => true,
            ],
            [
                'name' => 'Cuci Saja',
                'code' => 'WASH_ONLY',
                'description' => 'Layanan cuci tanpa setrika',
                'price_per_kg' => 3000,
                'standard_duration_hours' => 12,
                'is_active' => true,
            ],
            [
                'name' => 'Dry Cleaning',
                'code' => 'DRY_CLEAN',
                'description' => 'Layanan dry cleaning untuk pakaian khusus',
                'price_per_kg' => 15000,
                'standard_duration_hours' => 48,
                'is_active' => true,
            ],
        ];

        foreach ($services as $service) {
            \\App\\Models\\LaundryService::create($service);
        }
    }

    private function createSampleData($admin, $branch): void
    {
        // Create sample customers
        $customers = [
            [
                'name' => 'Budi Santoso',
                'email' => '[email protected]',
                'phone' => '+6281234567890',
                'address' => 'Jl. Merdeka No. 123, Jakarta',
                'customer_type' => 'regular',
            ],
            [
                'name' => 'Siti Nurhaliza',
                'email' => '[email protected]',
                'phone' => '+6281234567891',
                'address' => 'Jl. Sudirman No. 456, Jakarta',
                'customer_type' => 'vip',
            ],
        ];

        foreach ($customers as $customerData) {
            \\App\\Models\\Customer::create($customerData);
        }

        // Create sample employees
        \\App\\Models\\Employee::create([
            'employee_id' => 'EMP001',
            'name' => 'Ahmad Kasir',
            'email' => 'ahmad@' . tenant('id') . '.example.com',
            'phone' => '+6281234567892',
            'address' => 'Jl. Kebon Jeruk No. 789',
            'position' => 'cashier',
            'branch_id' => $branch->id,
            'basic_salary' => 3000000,
            'hire_date' => now()->subMonths(6),
            'status' => 'active',
        ]);
    }

    private function sendTenantNotification(Tenant $tenant, string $type, ?string $reason = null): void
    {
        // Implementation untuk send notification
        // Email, SMS, atau push notification
    }

    private function createTenantBackup(Tenant $tenant): string
    {
        // Implementation untuk create database backup
        return "backup_path";
    }
}

Creating Tenant Middleware

Create middleware untuk handle tenant resolution dan access control:

php artisan make:middleware ResolveTenantMiddleware

Edit app/Http/Middleware/ResolveTenantMiddleware.php:

<?php

namespace App\\Http\\Middleware;

use Closure;
use Illuminate\\Http\\Request;
use App\\Models\\Tenant;
use Stancl\\Tenancy\\Middleware\\InitializeTenancyByDomain;
use Stancl\\Tenancy\\Exceptions\\TenantCouldNotBeIdentifiedException;

class ResolveTenantMiddleware extends InitializeTenancyByDomain
{
    public function handle(Request $request, Closure $next)
    {
        try {
            // Call parent middleware untuk resolve tenant
            $response = parent::handle($request, function ($request) use ($next) {
                // Additional checks after tenant is resolved
                $tenant = tenant();

                if (!$tenant) {
                    return $this->handleTenantNotFound($request);
                }

                // Check if tenant is active
                if (!$tenant->isActive()) {
                    return $this->handleInactiveTenant($request, $tenant);
                }

                // Check subscription status
                if ($tenant->subscription_status === 'expired') {
                    return $this->handleExpiredSubscription($request, $tenant);
                }

                // Check trial status
                if ($tenant->isOnTrial() && $tenant->getDaysLeftOnTrial() <= 0) {
                    return $this->handleTrialExpired($request, $tenant);
                }

                return $next($request);
            });

            return $response;
        } catch (TenantCouldNotBeIdentifiedException $e) {
            return $this->handleTenantNotFound($request);
        }
    }

    private function handleTenantNotFound(Request $request)
    {
        if ($request->expectsJson()) {
            return response()->json([
                'error' => 'Tenant not found',
                'message' => 'The requested domain is not associated with any active tenant.',
            ], 404);
        }

        return redirect()->to(config('app.central_url', '<https://saaslaundry.test>'))
                        ->with('error', 'Domain not found. Please check your URL.');
    }

    private function handleInactiveTenant(Request $request, Tenant $tenant)
    {
        if ($request->expectsJson()) {
            return response()->json([
                'error' => 'Tenant suspended',
                'message' => 'This account has been suspended. Please contact support.',
            ], 403);
        }

        return view('tenant.suspended', [
            'tenant' => $tenant,
            'support_email' => config('app.support_email', '[email protected]'),
        ]);
    }

    private function handleExpiredSubscription(Request $request, Tenant $tenant)
    {
        if ($request->expectsJson()) {
            return response()->json([
                'error' => 'Subscription expired',
                'message' => 'Your subscription has expired. Please renew to continue.',
            ], 402);
        }

        return view('tenant.subscription-expired', [
            'tenant' => $tenant,
            'payment_url' => route('tenant.subscription.renew'),
        ]);
    }

    private function handleTrialExpired(Request $request, Tenant $tenant)
    {
        if ($request->expectsJson()) {
            return response()->json([
                'error' => 'Trial expired',
                'message' => 'Your trial period has ended. Please subscribe to continue.',
            ], 402);
        }

        return view('tenant.trial-expired', [
            'tenant' => $tenant,
            'plans_url' => route('tenant.subscription.plans'),
        ]);
    }
}

Creating Tenant Feature Middleware

Create middleware untuk check tenant features:

php artisan make:middleware CheckTenantFeature

Edit app/Http/Middleware/CheckTenantFeature.php:

<?php

namespace App\\Http\\Middleware;

use Closure;
use Illuminate\\Http\\Request;

class CheckTenantFeature
{
    public function handle(Request $request, Closure $next, string $feature)
    {
        $tenant = tenant();

        if (!$tenant) {
            abort(404, 'Tenant not found');
        }

        if (!$tenant->canAccessFeature($feature)) {
            if ($request->expectsJson()) {
                return response()->json([
                    'error' => 'Feature not available',
                    'message' => "The '{$feature}' feature is not available in your current plan.",
                    'upgrade_url' => route('tenant.subscription.upgrade'),
                ], 403);
            }

            return view('tenant.feature-unavailable', [
                'feature' => $feature,
                'tenant' => $tenant,
                'upgrade_url' => route('tenant.subscription.upgrade'),
            ]);
        }

        return $next($request);
    }
}

Creating Tenant Routes

Create separate route files untuk tenant dan central app. Create routes/tenant.php:

<?php

use Illuminate\\Support\\Facades\\Route;
use App\\Http\\Controllers\\Tenant\\DashboardController;
use App\\Http\\Controllers\\Tenant\\OrderController;
use App\\Http\\Controllers\\Tenant\\CustomerController;
use App\\Http\\Controllers\\Tenant\\SubscriptionController;

/*
|--------------------------------------------------------------------------
| Tenant Routes
|--------------------------------------------------------------------------
*/

Route::middleware([
    'web',
    App\\Http\\Middleware\\ResolveTenantMiddleware::class,
])->group(function () {

    // Public tenant routes
    Route::get('/', function () {
        return view('tenant.welcome', ['tenant' => tenant()]);
    })->name('tenant.home');

    // Authentication routes untuk tenant
    Route::middleware('guest')->group(function () {
        Route::get('/login', [AuthController::class, 'showLogin'])->name('tenant.login');
        Route::post('/login', [AuthController::class, 'login']);
        Route::get('/register', [AuthController::class, 'showRegister'])->name('tenant.register');
        Route::post('/register', [AuthController::class, 'register']);
    });

    // Authenticated tenant routes
    Route::middleware('auth')->group(function () {
        Route::post('/logout', [AuthController::class, 'logout'])->name('tenant.logout');

        // Dashboard
        Route::get('/dashboard', [DashboardController::class, 'index'])->name('tenant.dashboard');

        // Orders
        Route::middleware(['feature:order_management'])->group(function () {
            Route::resource('orders', OrderController::class)->names([
                'index' => 'tenant.orders.index',
                'create' => 'tenant.orders.create',
                'store' => 'tenant.orders.store',
                'show' => 'tenant.orders.show',
                'edit' => 'tenant.orders.edit',
                'update' => 'tenant.orders.update',
                'destroy' => 'tenant.orders.destroy',
            ]);
        });

        // Customers
        Route::middleware(['feature:customer_management'])->group(function () {
            Route::resource('customers', CustomerController::class)->names([
                'index' => 'tenant.customers.index',
                'create' => 'tenant.customers.create',
                'store' => 'tenant.customers.store',
                'show' => 'tenant.customers.show',
                'edit' => 'tenant.customers.edit',
                'update' => 'tenant.customers.update',
                'destroy' => 'tenant.customers.destroy',
            ]);
        });

        // Multi-branch routes (feature-gated)
        Route::middleware(['feature:multi_branch'])->group(function () {
            Route::resource('branches', BranchController::class)->names('tenant.branches');
        });

        // Advanced reporting (feature-gated)
        Route::middleware(['feature:advanced_reporting'])->group(function () {
            Route::get('/reports/advanced', [ReportController::class, 'advanced'])->name('tenant.reports.advanced');
            Route::get('/analytics/detailed', [AnalyticsController::class, 'detailed'])->name('tenant.analytics.detailed');
        });

        // Subscription management
        Route::prefix('subscription')->name('tenant.subscription.')->group(function () {
            Route::get('/', [SubscriptionController::class, 'index'])->name('index');
            Route::get('/plans', [SubscriptionController::class, 'plans'])->name('plans');
            Route::post('/upgrade', [SubscriptionController::class, 'upgrade'])->name('upgrade');
            Route::get('/renew', [SubscriptionController::class, 'renew'])->name('renew');
            Route::post('/cancel', [SubscriptionController::class, 'cancel'])->name('cancel');
        });

        // Settings dan branding
        Route::prefix('settings')->name('tenant.settings.')->group(function () {
            Route::get('/', [SettingsController::class, 'index'])->name('index');
            Route::put('/general', [SettingsController::class, 'updateGeneral'])->name('general');
            Route::put('/branding', [SettingsController::class, 'updateBranding'])->name('branding');
        });
    });
});

Registering Tenant Routes

Update routes/web.php untuk register tenant routes:

<?php

use Illuminate\\Support\\Facades\\Route;

/*
|--------------------------------------------------------------------------
| Central Application Routes
|--------------------------------------------------------------------------
*/

// Central domain routes
foreach (config('tenancy.central_domains') as $domain) {
    Route::domain($domain)->group(function () {
        // Landing page, pricing, marketing routes
        Route::get('/', [HomeController::class, 'index'])->name('home');
        Route::get('/pricing', [HomeController::class, 'pricing'])->name('pricing');
        Route::get('/features', [HomeController::class, 'features'])->name('features');

        // Central authentication
        Route::middleware('guest')->group(function () {
            Route::get('/signup', [AuthController::class, 'showSignup'])->name('signup');
            Route::post('/signup', [AuthController::class, 'signup']);
            Route::get('/login', [AuthController::class, 'showLogin'])->name('login');
            Route::post('/login', [AuthController::class, 'login']);
        });

        // Admin routes (super admin)
        Route::prefix('admin')->middleware(['auth', 'role:super_admin'])->group(function () {
            Route::get('/dashboard', [AdminController::class, 'dashboard'])->name('admin.dashboard');
            Route::resource('tenants', TenantController::class)->names('admin.tenants');
            Route::resource('plans', PlanController::class)->names('admin.plans');
        });
    });
}

/*
|--------------------------------------------------------------------------
| Tenant Routes
|--------------------------------------------------------------------------
*/

// Tenant routes untuk semua domains except central
Route::middleware([
    'web',
    App\\Http\\Middleware\\ResolveTenantMiddleware::class,
])->group(base_path('routes/tenant.php'));

Creating Custom Tenancy Bootstrapper

Create custom bootstrapper untuk additional tenant-specific configurations:

php artisan make:class Tenancy/Bootstrappers/LaundryTenancyBootstrapper

Create app/Tenancy/Bootstrappers/LaundryTenancyBootstrapper.php:

<?php

namespace App\\Tenancy\\Bootstrappers;

use Stancl\\Tenancy\\Contracts\\TenancyBootstrapper;
use Stancl\\Tenancy\\Contracts\\Tenant;

class LaundryTenancyBootstrapper implements TenancyBootstrapper
{
    public function bootstrap(Tenant $tenant)
    {
        // Set tenant-specific configurations
        config([
            'app.name' => $tenant->business_name,
            'app.timezone' => $tenant->settings['timezone'] ?? 'Asia/Jakarta',
            'app.currency' => $tenant->settings['currency'] ?? 'IDR',
        ]);

        // Set tenant-specific mail configuration
        if (isset($tenant->settings['mail'])) {
            config(['mail' => array_merge(config('mail'), $tenant->settings['mail'])]);
        }

        // Set tenant-specific filesystem disks
        config([
            'filesystems.disks.tenant' => [
                'driver' => 'local',
                'root' => storage_path('app/tenants/' . $tenant->getTenantKey()),
                'url' => env('APP_URL') . '/storage/tenants/' . $tenant->getTenantKey(),
                'visibility' => 'public',
            ],
        ]);

        // Set tenant-specific cache prefix
        config([
            'cache.prefix' => 'tenant_' . $tenant->getTenantKey(),
        ]);

        // Set custom branding in views
        view()->share('tenantBranding', $tenant->branding);
        view()->share('tenantSettings', $tenant->settings);
    }

    public function revert()
    {
        // Revert configurations ke default
        config([
            'app.name' => env('APP_NAME', 'SaaS Laundry Management'),
            'app.timezone' => env('APP_TIMEZONE', 'UTC'),
        ]);
    }
}

Register Custom Middleware

Register custom middleware di app/Http/Kernel.php:

<?php

namespace App\\Http;

use Illuminate\\Foundation\\Http\\Kernel as HttpKernel;

class Kernel extends HttpKernel
{
    protected $routeMiddleware = [
        // ... existing middleware
        'tenant' => \\App\\Http\\Middleware\\ResolveTenantMiddleware::class,
        'feature' => \\App\\Http\\Middleware\\CheckTenantFeature::class,
    ];

    protected $middlewareAliases = [
        'tenant' => \\App\\Http\\Middleware\\ResolveTenantMiddleware::class,
        'feature' => \\App\\Http\\Middleware\\CheckTenantFeature::class,
    ];
}

Dengan implementation multi-tenancy yang comprehensive ini, SaaS Laundry Management kita sekarang bisa serve multiple tenants dengan complete data isolation, custom branding, feature-gated access, dan subscription-based access control. Setiap tenant punya database mereka sendiri, custom domain support, dan tenant-specific configurations yang make aplikasi kita truly scalable untuk enterprise customers.

Penutup - Bangun Karir Web Developer dengan BuildWithAngga

Journey SaaS Laundry Management yang Menginspirasi

Wow! Kita sudah menyelesaikan perjalanan yang luar biasa dalam membangun SaaS Laundry Management dari awal sampai advanced features. Dari setup Laravel 12 hingga multi-tenancy implementation yang complex, kita sudah explore hampir semua aspek modern web development yang dibutuhkan untuk build aplikasi SaaS enterprise-grade.

Project ini bukan cuma sekedar tutorial - ini adalah real-world application yang bisa langsung digunakan untuk business. Dengan fitur-fitur seperti comprehensive authentication, payment integration, analytics dashboard, dan multi-tenancy architecture, kamu sudah punya foundation yang solid untuk build aplikasi SaaS apapun.

Skills yang Sudah Kamu Kuasai

Melalui series ini, kamu sudah master:

  • Laravel 12 Advanced Development dengan best practices
  • Filament Admin Panel untuk rapid development
  • Payment Gateway Integration dengan Midtrans
  • Role-Based Access Control dengan Spatie Permission
  • Analytics Dashboard dengan interactive charts
  • Multi-Tenancy Architecture untuk SaaS scalability
  • Database Design untuk complex business requirements
  • Security Implementation untuk production-ready applications

Lanjutkan Journey dengan BuildWithAngga

Kalau kamu excited dengan apa yang sudah kita build dan ingin take your skills to the next level, BuildWithAngga adalah platform perfect untuk accelerate your learning journey sebagai web developer.

12 Benefit Belajar di BuildWithAngga

Portfolio Berkualitas Tinggi Build project-project real yang bisa langsung dipake di portfolio untuk impress recruiters dan clients

Akses Selamanya Sekali beli, akses materi selamanya tanpa expired - perfect untuk continuous learning

Mentor Berpengalaman Belajar dari praktisi industry yang sudah proven track record di perusahaan tech terkemuka

Studi Kasus Real Project-based learning dengan case studies yang diambil dari kebutuhan industry sesungguhnya

Community Support Join komunitas developer yang saling support dan share knowledge untuk growth bareng

Career Guidance Dapat mentoring untuk career path, interview preparation, dan strategy untuk dapat kerja remote

Latest Technology Stack Selalu update dengan technology terbaru yang in-demand di market

Practical Approach Focus ke hands-on coding dibanding teori yang boring - learn by doing!

Flexible Learning Belajar dengan pace kamu sendiri, kapan aja dan dimana aja sesuai schedule

Industry-Ready Skills Skill yang dipelajari directly applicable untuk kerja di startup, agency, atau freelancing

Remote Work Preparation Preparation khusus untuk remote work opportunities yang semakin banyak

Lifetime Updates Materi terus di-update sesuai perkembangan technology dan kebutuhan industry

Modal untuk Kerja Remote

Dengan skills dari BuildWithAngga, kamu akan punya competitive advantage untuk:

  • Freelancing dengan rates yang competitive
  • Remote Jobs di startup atau tech companies
  • Building SaaS Products sebagai side project atau main business
  • Consulting untuk businesses yang need digital transformation

Ready untuk Next Level?

Jangan biarkan momentum learning ini berhenti di sini. Web development adalah field yang terus evolving, dan staying updated dengan latest trends dan technologies adalah kunci untuk long-term success.

Explore BuildWithAngga sekarang dan take your web development skills ke level yang completely different. Dengan investment yang reasonable, kamu bisa transform career trajectory dan open up opportunities yang previously seemed impossible.

Remember - the best developers are not those who know everything, but those who never stop learning. Keep building, keep learning, dan yang paling penting - keep shipping awesome products!

Happy coding! 🚀