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.
| Task | React (custom) | Laravel + Filament |
|---|---|---|
| CRUD table with filters | 2-3 days | 30 minutes |
| Form with validation | 1-2 days | 15 minutes |
| Dashboard widgets | 1-2 days | 20 minutes |
| User management + RBAC | 3-5 days | 1 hour |
| Total for admin panel | 2-3 weeks | 2-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
Drivermodel represents thedriverstable 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?
- Single Responsibility — Each listener does one thing
- Testable — Test the suspension logic without sending real emails
- Extensible — Adding a new side effect? Just add a new listener
- Async-ready — Listeners can be queued to run in the background
When Laravel Beats Node.js
| Scenario | Better Choice | Why |
|---|---|---|
| Admin panels | Laravel | Filament is unmatched |
| PDF generation | Laravel | DomPDF / Snappy are mature |
| Queue/job processing | Laravel | Built-in queue system |
| Complex Eloquent queries | Laravel | Eloquent is more expressive than TypeORM |
| Real-time APIs | NestJS | Better async performance |
| WebSocket connections | NestJS | Native async support |
| TypeScript shared types | NestJS | Share 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.
