Getting Started with Laravel: A Developer's Practical Guide

The short version: Laravel's elegance comes from understanding the service container, Eloquent's query builder, and the queue architecture — not from memorizing artisan commands. This guide covers the pieces that matter for production apps, including where Laravel's "magic" becomes a maintenance problem.
Laravel is PHP's most popular framework, and it earns that position. But most Laravel tutorials teach you the surface — routes, controllers, Blade templates — and leave the pieces that matter for production applications unexplained. This guide assumes you've seen a Route::get() before and covers what you actually need to build something that holds up.
The service container: what's actually happening behind the facades
Laravel's facades look like static method calls but aren't. Cache::get('key') resolves the cache manager from the service container and calls get() on it. This matters for two reasons: testing and understanding what you can replace.
The service container is a dependency injection (DI) system. When Laravel resolves a class from the container, it inspects the constructor type hints and resolves each dependency automatically.
// Without DI — tightly coupled, hard to test
class OrderController extends Controller
{
public function store(Request $request)
{
$mailer = new OrderMailer(new SmtpTransport(...)); // manually wired
$mailer->sendConfirmation($request->all());
}
}
// With DI — container resolves OrderMailer automatically
class OrderController extends Controller
{
public function __construct(private OrderMailer $mailer) {}
public function store(Request $request)
{
$this->mailer->sendConfirmation($request->validated());
}
}
In the second example, you can bind a fake OrderMailer in tests without touching the controller. That's why the container matters.
Binding a custom implementation:
// AppServiceProvider.php
public function register(): void
{
$this->app->bind(PaymentGateway::class, StripeGateway::class);
// Or conditionally based on environment
$this->app->bind(StorageDriver::class, function () {
return app()->environment('testing')
? new FakeStorageDriver()
: new S3StorageDriver(config('filesystems.s3'));
});
}
Routing: beyond the basics
Laravel routes support grouping, middleware, naming, and resource conventions. The patterns that pay off most in larger applications:
// routes/api.php
Route::prefix('v1')->middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::apiResource('orders', OrderController::class);
// Generates: GET /v1/orders, POST /v1/orders, GET /v1/orders/{order},
// PUT /v1/orders/{order}, DELETE /v1/orders/{order}
Route::post('orders/{order}/cancel', [OrderController::class, 'cancel'])
->name('orders.cancel');
});
Route model binding resolves the Order instance automatically from the URL parameter:
// The {order} parameter resolves to an Order model automatically
public function show(Order $order): JsonResponse
{
return response()->json($order->load('items', 'customer'));
}
Custom binding when you need to find by a different key:
// RouteServiceProvider.php
Route::bind('order', function (string $value) {
return Order::where('reference_number', $value)->firstOrFail();
});
Eloquent ORM: relationships and the N+1 problem
Eloquent is where most Laravel developers spend the most time and where the most common performance problems originate.
Defining relationships:
class Order extends Model
{
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
}
The N+1 problem and how to find it:
// N+1 — fires 1 query for orders + 1 per order for customer = N+1 total
$orders = Order::all();
foreach ($orders as $order) {
echo $order->customer->name; // separate query per iteration
}
// Eager loading — fires 2 queries total regardless of collection size
$orders = Order::with('customer')->get();
// Multiple relationships
$orders = Order::with(['customer', 'items', 'items.product'])->get();
Install Laravel Debugbar in development (composer require barryvdh/laravel-debugbar --dev). It shows the exact SQL queries fired per request. You will find N+1 issues on day one.
Query scopes for reusable filters:
class Order extends Model
{
public function scopePending(Builder $query): Builder
{
return $query->where('status', 'pending');
}
public function scopeForCustomer(Builder $query, int $customerId): Builder
{
return $query->where('customer_id', $customerId);
}
}
// Usage — reads like English
Order::pending()->forCustomer(42)->with('items')->latest()->get();
Queues: what to offload and how
Any operation over ~500ms that doesn't need to block the response belongs in a queue. The most common candidates: email, PDF generation, image processing, third-party API calls, and webhook delivery.
// Create a job
class SendOrderConfirmation implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(private Order $order) {}
public function handle(Mailer $mailer): void
{
$mailer->to($this->order->customer->email)
->send(new OrderConfirmationMail($this->order));
}
// Retry up to 3 times if the job fails
public int $tries = 3;
// Wait 60 seconds before retry
public int $backoff = 60;
}
// Dispatch from a controller — returns immediately, job runs in background
SendOrderConfirmation::dispatch($order);
// Dispatch with delay
SendOrderConfirmation::dispatch($order)->delay(now()->addMinutes(5));
For production, use Redis as the queue driver — it's significantly faster than the database driver and doesn't lock tables under load. Run workers with Supervisor to keep them alive:
; /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=4
Authentication: Sanctum vs Passport
Most applications need Sanctum. It handles:
- SPA authentication — cookie-based sessions for same-domain frontends
- API tokens — simple opaque tokens for mobile apps and third-party scripts
// Issue a token (e.g., for a mobile app login)
$token = $user->createToken('mobile-app', ['orders:read', 'orders:write']);
return ['token' => $token->plainTextToken];
// Protect routes
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn(Request $r) => $r->user());
});
// Check token abilities
if ($request->user()->tokenCan('orders:write')) {
// proceed
}
Use Passport only when you're building an OAuth2 provider — authorization code flows, client credentials grants, or a public API where external developers authenticate their own applications. For anything else, Sanctum handles it with far less complexity.
Where Laravel's magic becomes a problem
Laravel is opinionated about hiding complexity. That's the source of its productivity advantage and its main maintenance liability.
Facades make tracing execution harder. Log::info('message') looks like a static call. It's actually resolving a LogManager from the container, which is binding to a Monolog\Logger, which is writing to a handler. When something goes wrong in production, this indirection makes debugging slower. Use app('log') or inject the LoggerInterface directly in performance-critical paths where you want the dependency chain visible.
Eloquent $fillable vs. $guarded. Setting protected $guarded = [] on a model (allowing all fields to be mass-assigned) is convenient until a user sends an unexpected field in a POST request and writes directly to a column you didn't intend. Always define explicit $fillable arrays on models that accept user input.
N+1 is not always obvious. Nested relationships accessed in Blade templates are the worst case — by the time you see the page render, Laravel has fired 40 queries and the Blade file looks clean. Enable Model::preventLazyLoading() in development and it will throw an exception every time you access an unloaded relationship. Painful to set up, extremely useful.
// AppServiceProvider.php
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
For teams building full-stack applications with Laravel on the backend and React or Next.js on the frontend, the Next.js modern web development guide covers how the two stacks connect via API resources and authentication headers.
Useful artisan commands for daily development
# Generate a model with migration, factory, seeder, and resource controller
php artisan make:model Order -mfsr --api
# Run migrations on a fresh database with seeders
php artisan migrate:fresh --seed
# Start queue worker (development)
php artisan queue:work
# Clear all caches
php artisan optimize:clear
# List all registered routes
php artisan route:list --path=api
# Tinker — interactive REPL for testing Eloquent queries
php artisan tinker
Frequently Asked Questions

Written by
FNA Team
CEO & Founder at FNA Technology
Specializing in AI, automation, and scalable software solutions — helping businesses leverage cutting-edge technology to drive growth and innovation.
Work with us