This article is available in Indonesian

🇮🇩 Baca dalam Bahasa Indonesia

May 14, 2026

🇬🇧 English

Fleet Management Part 4: Admin Panel & Business Logic with Laravel

Build a powerful admin panel with Laravel and Filament. Service layer patterns, Eloquent ORM, event-driven architecture, and when Laravel beats Node.js.

6 min read

Why Laravel? We Already Have NestJS!

This is the question every developer asks. Why use two backend languages in one project? The answer is pragmatic, not dogmatic.

Building an admin panel in React/Next.js takes weeks. You need to build tables with sorting, filtering, pagination. Forms with validation. File uploads. Dashboard widgets. User management. RBAC. Export to Excel.

Building the same admin panel in Laravel + Filament takes days. Filament gives you all of the above out-of-the-box, with a beautiful UI.

TaskReact (custom)Laravel + Filament
CRUD table with filters2-3 days30 minutes
Form with validation1-2 days15 minutes
Dashboard widgets1-2 days20 minutes
User management + RBAC3-5 days1 hour
Total for admin panel2-3 weeks2-3 days

Senior Decision: Use the right tool for the right job. NestJS excels at building APIs for frontend apps. Laravel + Filament excels at admin panels. A senior developer knows when to mix technologies — and when not to.


What is Laravel?

If you've never used Laravel, here's the quick version: Laravel is a PHP framework that follows the MVC pattern (Model-View-Controller).

Request comes in → Router → Controller → Service → Model → Database ↓ Response goes out ← Controller ← Service ← Model ← Database

MVC Explained Simply

  • Model = Your data. A Driver model represents the drivers table in your database.
  • View = What the user sees. In our case, Filament handles this automatically.
  • Controller = The traffic cop. Receives requests, calls the right service, returns responses.

Setting Up Laravel with Filament

composer create-project laravel/laravel fleet-admin
cd fleet-admin
composer require filament/filament
php artisan filament:install --panels

Creating the Driver Management Module

Let's build a complete driver management system:

Step 1: Create the Migration

// database/migrations/create_drivers_table.php
public function up(): void
{
    Schema::create('drivers', function (Blueprint $table) {
        $table->uuid('id')->primary();
        $table->string('name', 100);
        $table->string('email')->unique();
        $table->string('phone', 20);
        $table->string('license_number', 50)->unique();
        $table->date('license_expiry');
        $table->enum('status', ['active', 'inactive', 'suspended'])->default('active');
        $table->date('hired_at');
        $table->decimal('rating', 3, 2)->default(5.00);
        $table->timestamps();
    });
}

Step 2: Create the Eloquent Model

// app/Models/Driver.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Concerns\HasUuids;

class Driver extends Model
{
    use HasUuids;

    protected $fillable = [
        'name', 'email', 'phone',
        'license_number', 'license_expiry',
        'status', 'hired_at', 'rating',
    ];

    protected $casts = [
        'license_expiry' => 'date',
        'hired_at' => 'date',
        'rating' => 'decimal:2',
    ];

    // Eloquent Relationships
    public function vehicles()
    {
        return $this->hasMany(Vehicle::class, 'current_driver_id');
    }

    public function deliveries()
    {
        return $this->hasMany(Delivery::class);
    }

    // Eloquent Scopes — reusable query filters
    public function scopeActive($query)
    {
        return $query->where('status', 'active');
    }

    public function scopeExpiringLicense($query, int $days = 30)
    {
        return $query->where('license_expiry', '<=', now()->addDays($days));
    }

    // Accessors — computed properties
    public function getIsLicenseExpiredAttribute(): bool
    {
        return $this->license_expiry->isPast();
    }
}

Why Eloquent is powerful: Notice how we defined scopeActive(). Now anywhere in our code, we can write Driver::active()->get() instead of repeating where('status', 'active') everywhere. Scopes make your code DRY and readable.

Step 3: Create the Filament Resource

// app/Filament/Resources/DriverResource.php
namespace App\Filament\Resources;

use App\Models\Driver;
use Filament\Forms;
use Filament\Tables;
use Filament\Resources\Resource;

class DriverResource extends Resource
{
    protected static ?string $model = Driver::class;
    protected static ?string $navigationIcon = 'heroicon-o-user-group';
    protected static ?string $navigationGroup = 'Fleet Management';

    public static function form(Forms\Form $form): Forms\Form
    {
        return $form->schema([
            Forms\Components\Section::make('Personal Information')->schema([
                Forms\Components\TextInput::make('name')
                    ->required()
                    ->maxLength(100),
                Forms\Components\TextInput::make('email')
                    ->email()
                    ->required()
                    ->unique(ignoreRecord: true),
                Forms\Components\TextInput::make('phone')
                    ->tel()
                    ->required(),
            ])->columns(3),

            Forms\Components\Section::make('License & Status')->schema([
                Forms\Components\TextInput::make('license_number')
                    ->required()
                    ->unique(ignoreRecord: true),
                Forms\Components\DatePicker::make('license_expiry')
                    ->required()
                    ->after('today'),
                Forms\Components\Select::make('status')
                    ->options([
                        'active' => 'Active',
                        'inactive' => 'Inactive',
                        'suspended' => 'Suspended',
                    ])
                    ->required(),
                Forms\Components\DatePicker::make('hired_at')
                    ->required(),
            ])->columns(2),
        ]);
    }

    public static function table(Tables\Table $table): Tables\Table
    {
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
                Tables\Columns\TextColumn::make('license_number')->searchable(),
                Tables\Columns\BadgeColumn::make('status')
                    ->colors([
                        'success' => 'active',
                        'warning' => 'inactive',
                        'danger' => 'suspended',
                    ]),
                Tables\Columns\TextColumn::make('license_expiry')
                    ->date()
                    ->color(fn (Driver $record) =>
                        $record->is_license_expired ? 'danger' : null
                    ),
                Tables\Columns\TextColumn::make('rating')
                    ->sortable(),
            ])
            ->filters([
                Tables\Filters\SelectFilter::make('status')
                    ->options([
                        'active' => 'Active',
                        'inactive' => 'Inactive',
                        'suspended' => 'Suspended',
                    ]),
            ]);
    }
}

That's it. With roughly 80 lines of PHP, we have a complete driver management admin panel with search, sort, filter, create, edit, and delete. Try building that in React.


The Service Layer Pattern

Here's where many Laravel developers go wrong: they put all business logic in controllers. Let me show you the difference.

// ❌ Bad — "Fat Controller" anti-pattern
class DriverController extends Controller
{
    public function suspend(Request $request, string $id)
    {
        $driver = Driver::findOrFail($id);
        $driver->status = 'suspended';
        $driver->save();

        // Send notification email
        Mail::to($driver->email)->send(new DriverSuspendedMail($driver));

        // Log the action
        AuditLog::create([
            'action' => 'driver_suspended',
            'driver_id' => $driver->id,
            'performed_by' => auth()->id(),
        ]);

        // Unassign from vehicle
        Vehicle::where('current_driver_id', $driver->id)
            ->update(['current_driver_id' => null]);

        return response()->json(['message' => 'Driver suspended']);
    }
}
// ✅ Good — Service Layer pattern
// app/Services/DriverService.php
namespace App\Services;

use App\Models\Driver;
use App\Events\DriverSuspended;

class DriverService
{
    public function suspend(string $driverId, string $performedBy): Driver
    {
        $driver = Driver::findOrFail($driverId);
        $driver->update(['status' => 'suspended']);

        // Fire an event — let listeners handle side effects
        event(new DriverSuspended($driver, $performedBy));

        return $driver;
    }
}

// app/Events/DriverSuspended.php — The Event
class DriverSuspended
{
    public function __construct(
        public readonly Driver $driver,
        public readonly string $performedBy,
    ) {}
}

// app/Listeners/SendDriverSuspensionEmail.php — Listener 1
class SendDriverSuspensionEmail
{
    public function handle(DriverSuspended $event): void
    {
        Mail::to($event->driver->email)
            ->send(new DriverSuspendedMail($event->driver));
    }
}

// app/Listeners/LogDriverSuspension.php — Listener 2
class LogDriverSuspension
{
    public function handle(DriverSuspended $event): void
    {
        AuditLog::create([
            'action' => 'driver_suspended',
            'driver_id' => $event->driver->id,
            'performed_by' => $event->performedBy,
        ]);
    }
}

// app/Listeners/UnassignDriverVehicles.php — Listener 3
class UnassignDriverVehicles
{
    public function handle(DriverSuspended $event): void
    {
        Vehicle::where('current_driver_id', $event->driver->id)
            ->update(['current_driver_id' => null]);
    }
}

Why is the Service + Event approach better?

  1. Single Responsibility — Each listener does one thing
  2. Testable — Test the suspension logic without sending real emails
  3. Extensible — Adding a new side effect? Just add a new listener
  4. Async-ready — Listeners can be queued to run in the background

When Laravel Beats Node.js

ScenarioBetter ChoiceWhy
Admin panelsLaravelFilament is unmatched
PDF generationLaravelDomPDF / Snappy are mature
Queue/job processingLaravelBuilt-in queue system
Complex Eloquent queriesLaravelEloquent is more expressive than TypeORM
Real-time APIsNestJSBetter async performance
WebSocket connectionsNestJSNative async support
TypeScript shared typesNestJSShare types with frontend

Senior Perspective: The "PHP is dead" crowd is wrong. PHP 8.3+ is fast, typed, and modern. Laravel 11+ is one of the best frameworks in any language. A senior developer evaluates tools objectively, not tribally.


What's Next

In Part 5, we'll design our database architecture — why we use PostgreSQL for telemetry, MySQL for business data, and Redis for caching. We'll cover schema design, migrations, indexing strategies, and query optimization.


This is Part 4 of the Fleet Management System series. We've shown that choosing the right tool for each job — even if it means mixing languages — is a mark of senior engineering judgment.

Continue Reading

Previous article

← Previous Article

Fleet Management Part 3: Backend API with NestJS

Next Article →

Fleet Management Part 5: Database Design — PostgreSQL, MySQL & Redis

Next article