Laravel Best Practices for Clean, Maintainable Code
By Pawan Singh · Jun 2026
Most Laravel codebases don't get unmaintainable from one bad decision — they accumulate small ones. These are the habits that keep a project workable as it grows past the size of a tutorial.
Keep Controllers Thin
A controller method's job is to orchestrate — validate input, call the code that does the actual work, return a response. It's not the place for business logic:
// Avoid: business logic living directly in the controller
public function store(Request $request)
{
$validated = $request->validate([...]);
$order = Order::create($validated);
Mail::to($order->user)->send(new OrderConfirmation($order));
Inventory::decrement($order->items);
return redirect()->route('orders.show', $order);
}
// Better: controller orchestrates, an action class does the work
public function store(StoreOrderRequest $request, CreateOrder $createOrder)
{
$order = $createOrder($request->validated());
return redirect()->route('orders.show', $order);
}
A single-purpose "action" class (CreateOrder, invoked via __invoke()) is easy to unit test in isolation and easy to reuse from a queued job, an Artisan command, or an API endpoint — none of which is true of logic wedged inside one specific controller method.
Use Form Requests for Validation
Move validation rules into a dedicated FormRequest class rather than inline $request->validate([...]) calls scattered across controllers:
php artisan make:request StoreOrderRequest
This keeps validation rules discoverable in one place per action, testable independently, and gives you a natural home for authorization logic (authorize()) separate from data-shape rules (rules()).
Avoid Fat Models Full of Unrelated Concerns
"Skinny controllers, fat models" was reasonable early Laravel advice, but taken too far it just relocates the mess — a User model with 800 lines covering authentication, billing, notifications, and reporting is no more maintainable than a fat controller. Extract genuinely separate concerns into their own classes (a BillingService, a dedicated notification class) and keep the model focused on data access and relationships.
Eager Load to Avoid N+1 Queries
// Bad: 1 query for posts + N queries, one per post's author
foreach (Post::all() as $post) {
echo $post->author->name;
}
// Good: 2 queries total, regardless of row count
foreach (Post::with('author')->get() as $post) {
echo $post->author->name;
}
Enable Model::preventLazyLoading() (in AppServiceProvider's boot()) during local development so a missing with() throws immediately in your own testing rather than silently shipping a slow query to production, where it usually isn't noticed until the table has real data volume.
Use Database Transactions for Multi-Step Writes
Any operation that writes to more than one table and needs to succeed or fail as a unit belongs in a transaction:
DB::transaction(function () use ($order) {
$order->save();
$order->items()->createMany($items);
Inventory::decrement($items);
});
Without this, a failure partway through (a validation error, a deadlock, an exception in the third step) leaves the database in an inconsistent half-written state — an order saved with no line items, or inventory decremented with no corresponding order. DB::transaction() rolls back everything inside the closure automatically if any exception is thrown.
Don't Overuse Facades in Testable Code
Facades (Cache::, Mail::, Storage::) are genuinely fine and idiomatic in Laravel — they're not the anti-pattern some non-Laravel developers assume, since Laravel's testing helpers (Mail::fake(), Storage::fake()) make them straightforward to test. The real risk is depending directly on a concrete third-party SDK client scattered across many classes instead of wrapping it once behind your own interface, which makes swapping providers or mocking in tests much harder later.
Write Feature Tests for Critical Paths
You don't need 100% coverage, but the paths that would actually hurt if silently broken — checkout, authentication, anything involving money or irreversible actions — deserve real feature tests, not just manual clicking:
public function test_guest_cannot_access_dashboard(): void
{
$this->get('/dashboard')->assertRedirect('/login');
}
Use Laravel Pint for Consistent Formatting
Laravel ships with Pint, a zero-config code style fixer built on PHP-CS-Fixer:
./vendor/bin/pint
Running it in CI (or as a pre-commit hook) removes an entire category of pull request review comments about spacing and brace placement that have nothing to do with whether the code is correct.
Keep .env Out of Version Control, But .env.example Current
This sounds obvious and still goes wrong constantly: a new environment variable gets added to .env locally, works fine for the person who added it, and nobody else's setup breaks until they pull the change and hit a confusing missing-config error. Update .env.example in the same commit that introduces a new environment variable.
Reviewing or refactoring an existing Laravel codebase? Get in touch — I work as a freelance Laravel developer.