Easy and Quick POS System Using Laravel Filament

Programmer TeloProgrammer Telo
8 min read

Step 1: Create the Laravel Project

To create a new Laravel project named laravel-pos, run:

laravel new laravel-pos

Alternatively, you can use Composer:

composer create-project laravel/laravel=11.3.2 laravel-pos

Step 2: Open the Project in Your Code Editor

Navigate into the project directory and open it in your code editor:

cd laravel-pos
code .
npm install && npm run build
composer run dev

Step 3: Configure the Database

Open the .env file in your project root and update the database settings. Since Laravel's installer already configured the database and ran migrations during setup, this step is complete.

Step 3: Install Filament

  1. Install Filament: Run the following command to install Filament version 3.2:

     composer require filament/filament:"^3.2" -W
    
  2. Install Admin Panel: Install the Filament admin panel using the command:

     php artisan filament:install --panels
    

Step 4: Create Admin User

To create an admin user for your Filament admin panel, run the following command:

php artisan make:filament-user

Step 5: Create Location Management

To set up models, migrations, and seeders for Provinces, Regencies, Districts, and Villages, follow these steps:

1. Generate Models and Migrations

Run the following commands to create the models with their corresponding migrations and seeders:

php artisan make:model Province -ms
php artisan make:model Regency -ms
php artisan make:model District -ms
php artisan make:model Village -ms

2. Define Migrations

Replace the content of the generated migration files as follows:

Provinces Migration:

<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('provinces', function (Blueprint $table) {
            $table->unsignedTinyInteger('id')->autoIncrement()->index();
            $table->string('name', 50)->nullable(false);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('provinces');
    }
};

Regencies Migration:

<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('regencies', function (Blueprint $table) {
            $table->unsignedSmallInteger('id')->autoIncrement()->index();
            $table->unsignedTinyInteger('province_id');
            $table->string('name', 50)->nullable(false);
            $table->foreign('province_id')->references('id')->on('provinces')->restrictOnDelete()->cascadeOnUpdate();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('regencies');
    }
};

Districts Migration:

<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('districts', function (Blueprint $table) {
            $table->unsignedMediumInteger('id')->autoIncrement()->index();
            $table->unsignedSmallInteger('regency_id');
            $table->string('name', 50)->nullable(false);
            $table->foreign('regency_id')->references('id')->on('regencies')->cascadeOnUpdate()->restrictOnDelete();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('districts');
    }
};

Villages Migration:

<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('villages', function (Blueprint $table) {
            $table->unsignedBigInteger('id')->autoIncrement()->index();
            $table->unsignedMediumInteger('district_id');
            $table->string('name', 50)->nullable(false);
            $table->foreign('district_id')->references('id')->on('districts')->cascadeOnUpdate()->restrictOnDelete();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('villages');
    }
};

3. Prepare Seeder Data

Create a directory for seeder data:

mkdir database/seeders/data

Download seeder data using the following commands:

curl -o "database/seeders/data/laravel_pos_districts.sql" "https://raw.githubusercontent.com/CahBantul/laravel-pos/develop/database/seeders/data/laravel_pos_districts.sql"
curl -o "database/seeders/data/laravel_pos_provinces.sql" "https://raw.githubusercontent.com/CahBantul/laravel-pos/develop/database/seeders/data/laravel_pos_provinces.sql"
curl -o "database/seeders/data/laravel_pos_regencies.sql" "https://raw.githubusercontent.com/CahBantul/laravel-pos/develop/database/seeders/data/laravel_pos_regencies.sql"
curl -o "database/seeders/data/laravel_pos_villages.sql" "https://raw.githubusercontent.com/CahBantul/laravel-pos/develop/database/seeders/data/laravel_pos_villages.sql"

4. Define Seeders

Edit the seeders for each table as follows:

ProvinceSeeder:

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;

class ProvinceSeeder extends Seeder
{
    public function run(): void
    {
        $sqlPath = database_path('seeders/data/laravel_pos_provinces.sql');
        if (File::exists($sqlPath)) {
            DB::unprepared(File::get($sqlPath));
            $this->command->info('Provinces data seeded successfully!');
        } else {
            $this->command->error('Provinces SQL file not found.');
        }
    }
}

Similarly, edit RegencySeeder, DistrictSeeder, and VillageSeeder, updating the $sqlPath and success/error messages accordingly.

5. Add Seeders to DatabaseSeeder

Edit the DatabaseSeeder file to include the new seeders:

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this->call(ProvinceSeeder::class);
        $this->call(RegencySeeder::class);
        $this->call(DistrictSeeder::class);
        $this->call(VillageSeeder::class);
    }
}

6. Run Migrations and Seeders

Run the following commands to apply migrations and seed the database:

php artisan migrate
php artisan db:seed

Step 6: Create Filament Resource

to create filament resource use this command

php artisan make:filament-resource Province --generate
php artisan make:filament-resource Regency --generate
php artisan make:filament-resource District --generate
php artisan make:filament-resource Village --generate

Don’t forget to define relationships in the respective models. With these commands, you’ll instantly have CRUD forms generated for each resource. 🎉

Step 7: Validate Unique for Province, Regency, District, and Village

To validate uniqueness for the Province, Regency, District, and Village resources, you will add the unique validation rule and ensure that the proper relationships are defined in both the models and resources.

1. Province Resource

In the ProvinceResource.php, you can add the unique validation rule for the name field and ensure the model's fillable property includes the name attribute.

ProvinceResource.php:

public static function form(Form $form): Form
{
    return $form
        ->schema([
            Forms\Components\TextInput::make('name')
                ->unique(ignoreRecord: true)  // Ensures unique validation while ignoring the current record
                ->required()
                ->maxLength(50),
        ]);
}

Province Model:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Province extends Model
{
    protected $fillable = ['name']; // Make sure 'name' is fillable for mass assignment
}

2. Regency Resource

In the RegencyResource.php, you'll validate the uniqueness of the name within a specific province_id by adding the custom rule validation.

RegencyResource.php:

public static function form(Form $form): Form
{
    return $form
        ->schema([
            Forms\Components\Select::make('province_id')
                ->relationship('province', 'name')  // Establish relationship with Province
                ->required(),
            Forms\Components\TextInput::make('name')
                ->rule(function (Get $get, $record) {
                    $recordId = $record?->id;
                    $provinceId = $get('province_id');
                    return "unique:regencies,name,{$recordId},id,province_id,{$provinceId}";  // Custom unique rule to check uniqueness in the context of the selected province
                })
                ->required()
                ->maxLength(50),
        ]);
}

3. District Resource

Similarly, for the District resource, you will validate uniqueness of the name within a specific regency_id.

DistrictResource.php:

public static function form(Form $form): Form
{
    return $form
        ->schema([
            Forms\Components\Select::make('regency_id')
                ->relationship('regency', 'name')  // Relationship with Regency
                ->required(),
            Forms\Components\TextInput::make('name')
                ->rule(function (Get $get, $record) {
                    $recordId = $record?->id;
                    $regencyId = $get('regency_id');
                    return "unique:districts,name,{$recordId},id,regency_id,{$regencyId}";  // Ensure the district name is unique within the specific regency
                })
                ->required()
                ->maxLength(50),
        ]);
}

4. Village Resource

Lastly, for the Village resource, validate the uniqueness of the name within a specific district_id.

VillageResource.php:

public static function form(Form $form): Form
{
    return $form
        ->schema([
            Forms\Components\Select::make('district_id')
                ->relationship('district', 'name')  // Relationship with District
                ->required(),
            Forms\Components\TextInput::make('name')
                ->rule(function (Get $get, $record) {
                    $recordId = $record?->id;
                    $districtId = $get('district_id');
                    return "unique:villages,name,{$recordId},id,district_id,{$districtId}";  // Ensure the village name is unique within the specific district
                })
                ->required()
                ->maxLength(50),
        ]);
}

Important Notes:

  • Make sure that the relationships (province, regency, district) are correctly defined in the models.

  • The ignoreRecord: true option is used in the Province resource to ensure that the current record is ignored when checking for uniqueness during updates (i.e., it doesn't consider the record itself as a duplicate).

  • The custom rule method in the Regency, District, and Village resources ensures that the uniqueness check is limited to the parent resource (e.g., province, regency, or district).

Step 8: Create Customer and Supplier Models, Migrations, and Resources

1. Create Models and Migrations

First, create the Customer and Supplier models along with their migrations:

php artisan make:model Customer -m
php artisan make:model Supplier -m

2. Edit Customer Migration

Modify the up method in the Customer migration file as follows:

<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('customers', function (Blueprint $table) {
            $table->id();
            $table->string('name', 100);
            $table->string('email', 100);
            $table->string('phone', 20);
            $table->string('address', 200);
            $table->unsignedTinyInteger('province_id');
            $table->unsignedSmallInteger('regency_id');
            $table->unsignedMediumInteger('district_id');
            $table->unsignedBigInteger('village_id');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('customers');
    }
};

Run the migration:

php artisan migrate

You can replicate the same structure for the Supplier table, adjusting as needed for specific requirements.


3. Define Relationships in Models

Update the Customer model to include relationships with related location entities (Province, Regency, District, and Village):

<?php

namespace App\Models;

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

class Customer extends Model
{
    protected $fillable = ['name', 'email', 'phone', 'address', 'province_id', 'regency_id', 'district_id', 'village_id'];

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

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

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

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

Repeat this process for the Supplier model.


4. Generate Filament Resources

Generate resources for Customer and Supplier:

php artisan make:filament-resource Customer --generate --view
php artisan make:filament-resource Supplier --generate --view

5. Edit the CustomerResource

Customize the CustomerResource class to define the form schema and table columns. Here’s the complete code:

<?php

namespace App\Filament\Resources;

use App\Filament\Resources\CustomerResource\Pages;
use App\Models\Customer;
use App\Models\District;
use App\Models\Regency;
use App\Models\Village;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;

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

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

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\Components\TextInput::make('name')
                    ->required()
                    ->maxLength(100),
                Forms\Components\TextInput::make('email')
                    ->email()
                    ->required()
                    ->maxLength(100),
                Forms\Components\TextInput::make('phone')
                    ->tel()
                    ->required()
                    ->maxLength(20),
                Forms\Components\TextInput::make('address')
                    ->required()
                    ->maxLength(100),
                Forms\Components\Select::make('province_id')
                    ->relationship('province', 'name')
                    ->searchable()
                    ->preload()
                    ->live()
                    ->afterStateUpdated(function (Set $set) {
                        $set('regency_id', null);
                        $set('district_id', null);
                        $set('village_id', null);
                    })
                    ->required(),
                Forms\Components\Select::make('regency_id')
                    ->options(fn(Get $get) => Regency::query()->where('province_id', $get('province_id'))->pluck('name', 'id'))
                    ->searchable()
                    ->preload()
                    ->live()
                    ->afterStateUpdated(function (Set $set) {
                        $set('district_id', null);
                        $set('village_id', null);
                    })
                    ->required(),
                Forms\Components\Select::make('district_id')
                    ->options(fn(Get $get) => District::query()->where('regency_id', $get('regency_id'))->pluck('name', 'id'))
                    ->searchable()
                    ->preload()
                    ->live()
                    ->afterStateUpdated(function (Set $set) {
                        $set('village_id', null);
                    })
                    ->required(),
                Forms\Components\Select::make('village_id')
                    ->options(fn(Get $get) => Village::query()->where('district_id', $get('district_id'))->pluck('name', 'id'))
                    ->searchable()
                    ->preload()
                    ->required(),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('name')->searchable(),
                Tables\Columns\TextColumn::make('email')->searchable(),
                Tables\Columns\TextColumn::make('phone')->searchable(),
                Tables\Columns\TextColumn::make('address')->searchable(),
                Tables\Columns\TextColumn::make('province.name')->sortable(),
                Tables\Columns\TextColumn::make('regency.name')->sortable(),
                Tables\Columns\TextColumn::make('district.name')->sortable(),
                Tables\Columns\TextColumn::make('village.name')->sortable(),
                Tables\Columns\TextColumn::make('created_at')->dateTime()->sortable(),
                Tables\Columns\TextColumn::make('updated_at')->dateTime()->sortable(),
            ])
            ->filters([])
            ->actions([
                Tables\Actions\ViewAction::make(),
                Tables\Actions\EditAction::make(),
            ])
            ->bulkActions([
                Tables\Actions\BulkActionGroup::make([
                    Tables\Actions\DeleteBulkAction::make(),
                ]),
            ]);
    }

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

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

6. Repeat for SupplierResource

Follow similar steps to customize the SupplierResource class.


7. Organize Resources into a Submenu

To group the Customer and Supplier resources under a Parties submenu, adjust the navigation configuration in both resources:

protected static ?string $navigationGroup = 'Parties';

With these steps, your Customer and Supplier management systems are ready, including location-based dynamic dropdowns and seamless integration with Filament.

2
Subscribe to my newsletter

Read articles from Programmer Telo directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Programmer Telo
Programmer Telo