Laravel Best Practices: Building Flexible, Secure, and Scalable Applications
A practical reference for writing Laravel applications that stay maintainable as they grow — covering SOLID principles, service-layer architecture, security hardening, caching, queues, and scaling patterns for real production systems.
Laravel Best Practices: Building Flexible, Secure, and Scalable Applications
Laravel makes it easy to ship features fast — which is exactly why so many Laravel codebases end up hard to maintain. Fat controllers, business logic scattered across Eloquent models, N+1 queries hiding in Blade views, and no clear boundary between "what the framework does" and "what your application does." None of this is inevitable. This guide covers the principles and concrete patterns that keep a Laravel app flexible, secure, and scalable as it grows from a weekend project into a system serving real traffic.
1. Architectural Principles
Keep Controllers Thin
A controller's job is to translate an HTTP request into an action and return a response — nothing more. Business logic, validation rules, and data access don't belong there.
// Avoid
class OrderController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([...]);
$order = Order::create($validated);
$order->items()->createMany($request->items);
Mail::to($request->user())->send(new OrderConfirmation($order));
Inventory::where('sku', $request->sku)->decrement('stock', $request->qty);
return response()->json($order);
}
}
// Prefer
class OrderController extends Controller
{
public function store(StoreOrderRequest $request, CreateOrderAction $action)
{
$order = $action->execute($request->validated());
return OrderResource::make($order);
}
}
Moving validation into a Form Request, and business logic into an Action or Service class, makes the controller trivially easy to read and the logic independently testable without spinning up HTTP requests.
Apply SOLID Where It Actually Helps
You don't need to abstract everything, but a few principles pay off consistently in Laravel apps:
- Single Responsibility — a class that sends emails shouldn't also calculate pricing. Split
OrderServiceintoPricingService,OrderNotifier, etc. once it grows past one clear responsibility. - Dependency Inversion — depend on interfaces, not concrete implementations, for anything that might change (payment gateways, notification channels, storage drivers). Laravel's service container makes this nearly free:
interface PaymentGateway
{
public function charge(int $amountCents, string $token): PaymentResult;
}
class StripeGateway implements PaymentGateway { /* ... */ }
class MpesaGateway implements PaymentGateway { /* ... */ }
// AppServiceProvider
$this->app->bind(PaymentGateway::class, function () {
return match (config('services.payment.default')) {
'stripe' => new StripeGateway(),
'mpesa' => new MpesaGateway(),
};
});
Now swapping payment providers — or adding a second one — doesn't touch a single controller.
Use Domain-Oriented Folders for Larger Apps
The default app/Http/Controllers, app/Models structure works fine for small apps, but for larger domains, grouping by feature scales better than grouping by type:
app/ Domain/ Orders/ Actions/CreateOrderAction.php Models/Order.php Services/PricingService.php Payments/ Contracts/PaymentGateway.php Gateways/StripeGateway.php
This isn't mandatory, but on any codebase with more than a handful of developers, it drastically reduces the cognitive load of finding related code.
2. Flexibility
Favor Composition Over Inheritance
Laravel encourages traits and interfaces over deep inheritance chains. A Notifiable, HasApiTokens, SoftDeletes composition on a model is easier to reason about than a five-level class hierarchy.
Configuration, Not Hardcoding
Anything likely to change between environments or over time belongs in config, not scattered as magic values in code:
// Avoid
if ($order->total > 50000) { ... }
// Prefer
if ($order->total > config('orders.high_value_threshold')) { ... }
Use Events for Decoupling
When one action should trigger several unrelated side effects (send email, update analytics, notify Slack), don't chain them in a service method — fire an event and let listeners handle each concern independently.
event(new OrderPlaced($order));
protected $listen = [
OrderPlaced::class => [
SendOrderConfirmationEmail::class,
DecrementInventory::class,
NotifySalesChannel::class,
],
];
This means adding a new side effect later (e.g., a loyalty points system) requires zero changes to existing code — just a new listener.
3. Security
Mass Assignment Protection
Always define $fillable explicitly rather than $guarded = []. An empty guarded array is a common source of privilege-escalation bugs when request input is passed directly to create() or update().
protected $fillable = ['title', 'body', 'user_id'];
Validate Everything, Trust Nothing
Use Form Requests for all input validation, and be explicit rather than permissive:
public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255'],
'role' => ['required', Rule::in(['member', 'editor'])], // never trust a free-text role
];
}
Authorization via Policies, Not Ad-Hoc Checks
Scattering if ($user->id !== $post->user_id) checks throughout controllers is easy to get wrong and easy to forget. Centralize in Policies:
class PostPolicy
{
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id || $user->hasRole('admin');
}
}
$this->authorize('update', $post);
Guard Against SQL Injection and Raw Queries
Eloquent and the query builder parameterize queries automatically, but raw queries and whereRaw bypass this if you interpolate user input directly:
// Dangerous
DB::select("SELECT * FROM users WHERE email = '{$request->email}'");
// Safe
DB::select('SELECT * FROM users WHERE email = ?', [$request->email]);
Secrets and Environment Hygiene
- Never commit
.envfiles. - Rotate
APP_KEY, API keys, and database credentials if they're ever exposed. - Use Laravel's built-in encryption (
Crypt::encryptString()) for sensitive fields at rest rather than storing them plainly. - Enable HTTPS enforcement in production via
URL::forceScheme('https')or at the load balancer/proxy level.
Rate Limiting
Protect authentication and public API endpoints from abuse:
Route::middleware('throttle:5,1')->post('/login', [AuthController::class, 'login']);
Keep Dependencies Current
Run composer audit regularly and subscribe to Laravel security advisories — an outdated package is one of the most common real-world attack vectors, far more common than a framework-level vulnerability.
4. Scalability
Eliminate N+1 Queries
The single most common Laravel performance killer. Eager load relationships explicitly:
// N+1: one query per order to fetch its user
$orders = Order::all();
foreach ($orders as $order) {
echo $order->user->name;
}
// One query total
$orders = Order::with('user')->get();
Install Laravel Debugbar or use DB::listen() in local development to catch these before they reach production.
Push Heavy Work to Queues
Anything that doesn't need to complete before the response returns — emails, PDF generation, third-party API calls, image processing — belongs on a queue, not inline in the request cycle:
Mail::to($user)->queue(new WelcomeEmail($user));
ProcessVideoUpload::dispatch($video);
Use a real queue driver (Redis, SQS) in production — the sync and database drivers don't scale under load.
Cache Deliberately
Cache expensive, infrequently-changing reads — not everything, and not blindly.
$stats = Cache::remember('dashboard_stats:' . $user->id, now()->addMinutes(10), function () use ($user) {
return $this->computeExpensiveStats($user);
});
Use tagged caches (with Redis) to invalidate related groups of keys cleanly rather than clearing the entire cache store on every write.
Database Indexing and Query Discipline
- Index foreign keys and any column used in
WHERE,ORDER BY, orJOINclauses. - Use
chunk()orlazy()for processing large datasets instead of loading everything into memory:
User::where('active', true)->chunk(500, function ($users) {
foreach ($users as $user) {
// process
}
});
- Avoid
select *on wide tables when you only need a few columns — it adds up at scale.
Horizontal Scaling Readiness
To run multiple app servers behind a load balancer, your app must be stateless:
- Sessions in Redis or a database, not the local filesystem (
SESSION_DRIVER=redis). - Uploaded files in S3 or similar object storage, never local disk (
FILESYSTEM_DISK=s3). - Cache in Redis/Memcached, not
fileorarraydrivers.
Read/Write Splitting for High-Traffic Databases
Once a single database instance becomes the bottleneck, Laravel supports read/write connection splitting natively:
'mysql' => [
'read' => ['host' => [env('DB_READ_HOST')]],
'write' => ['host' => [env('DB_WRITE_HOST')]],
'sticky' => true,
// ...
],
5. Testing as a Scalability Enabler
Tests aren't just about correctness — they're what makes it safe to refactor and scale a codebase without fear. A well-tested CreateOrderAction can be optimized, cached, or parallelized with confidence; an untested one can't be touched without risking a production incident.
public function test_order_is_created_with_correct_total(): void
{
$product = Product::factory()->create(['price' => 1000]);
$order = app(CreateOrderAction::class)->execute([
'product_id' => $product->id,
'quantity' => 3,
]);
$this->assertEquals(3000, $order->total);
}
Prioritize feature tests around business-critical flows (checkout, auth, payments) and unit tests around isolated logic (pricing calculations, formatters).
Quick Reference Checklist
- Controllers stay thin — logic lives in Actions/Services
- Depend on interfaces for anything swappable (payments, storage, notifications)
-
$fillableexplicit on every model - Authorization via Policies, not inline checks
- All input validated via Form Requests
- No raw SQL with interpolated user input
- N+1 queries eliminated via eager loading
- Heavy work offloaded to queues
- Sessions, cache, and files in shared/external stores (Redis, S3) — not local disk
- Rate limiting on public and auth endpoints
- Dependencies audited regularly for known vulnerabilities
Wrapping Up
None of these practices are exotic — they're mostly discipline applied consistently. The teams that keep Laravel codebases maintainable at scale aren't doing anything magical; they're just consistently keeping business logic out of controllers, trusting nothing from user input, watching their query counts, and designing for statelessness from day one rather than retrofitting it under pressure. Start applying these incrementally on your next feature rather than attempting a big-bang refactor — the return compounds fast.