3 Laravel Mistakes That Break Production (And Fixes)
Maksudur Rahman
Software Engineer
Laravel makes building web applications remarkably fast. Its expressive Eloquent ORM, built-in helpers, and elegant facade abstractions allow you to deliver features in days instead of weeks. However, patterns that work effortlessly on a local development machine with 10 test records often degrade or fail under production load with 100,000 active users.
When traffic scales, small architectural shortcuts trigger catastrophic database lockups, silent authentication failures, and memory exhaustion.
Quick Summary / Key Takeaways
The 3 Laravel mistakes that most commonly cause production outages are:
The N+1 Query Cascade: Uncontrolled relationship hydration and missing eager loading constraints that exhaust database connection pools and memory.
Network I/O Inside Database Transactions: Executing third-party API calls (e.g., Stripe, Mailgun) inside
DB::transaction()blocks, holding row locks open during network latency.Direct
env()Helper Calls Outside Config Files: Reading environment variables directly in application code, which turn intonullwhen runningphp artisan config:cache.
Production Impact Overview
Mistake | Immediate Production Symptom | Root Cause | Engineering Solution |
|---|---|---|---|
1. Unrestricted N+1 Queries | 504 Gateway Timeouts, 100% CPU on MySQL/PostgreSQL | Lazy loading inside loops and API resources | Eager loading with select constraints, |
2. Network I/O in DB Transactions | Deadlocks, connection exhaustion, duplicate charges | Holding database row locks during external HTTP latency | Micro-scoped transactions + |
3. Direct | Silent API authentication errors, null credentials |
| Centralized config files + typed |
1. The N+1 Query Cascade & Unrestricted Relationship Hydration
The Problem
The N+1 query problem is the single most frequent database performance bottleneck in Laravel applications. It occurs when your code queries a parent record and then executes an additional query for every child record in a loop.
In local development with 5 users, running 6 queries takes 2 milliseconds and goes unnoticed. In production with a paginated list of 50 users who each have 20 orders, your database suddenly receives 1,001 queries for a single HTTP request.
flowchart TD
A["Controller: User::all()"] -->|1 Query| B["Fetch 50 Users"]
B --> C["Loop / API Resource"]
C -->|50 Queries| D["Fetch Orders for User 1..50"]
C -->|50 Queries| E["Fetch Invoices for User 1..50"]
D & E --> F["504 Gateway Timeout / DB Saturation"]The Flawed Code (Anti-Pattern)
// app/Http/Controllers/OrderReportController.php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\JsonResponse;
class OrderReportController extends Controller
{
public function index(): JsonResponse
{
// Fetches 50 users in 1 query
$users = User::where('is_active', true)->paginate(50);
$report = $users->map(function (User $user) {
// BUG: Triggers 2 additional queries per user iteration (N+1)
// Plus loads every single column into memory
return [
'id' => $user->id,
'name' => $user->name,
'latest_order_total' => $user->orders()->latest()->first()?->total_amount,
'total_spent' => $user->orders()->sum('total_amount'),
'department_name' => $user->department?->name,
];
});
return response()->json($report);
}
}The Refactored Solution
To resolve this, apply three practices:
Eager load known relationships using
with().Push aggregation (sums, counts) into the database query rather than computing it in PHP collections.
Select only the exact columns required to reduce PHP memory overhead.
// app/Http/Controllers/OrderReportController.php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
class OrderReportController extends Controller
{
public function index(): JsonResponse
{
// Executes exactly 2 optimized SQL queries regardless of record count
$users = User::query()
->select(['id', 'name', 'department_id'])
->where('is_active', true)
->with([
'department:id,name',
])
->withSum('orders as total_spent', 'total_amount')
->addSelect([
'latest_order_total' => DB::table('orders')
->select('total_amount')
->whereColumn('orders.user_id', 'users.id')
->latest('created_at')
->limit(1),
])
->paginate(50);
return response()->json($users);
}
}Strict Mode Guardrail inAppServiceProvider
Instead of relying on code reviews to catch lazy loading, enforce strictness at the framework level during local development and testing.
In app/Providers/AppServiceProvider.php:
namespace App\Providers;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Disable lazy loading in non-production environments.
// If an N+1 query is introduced, Laravel throws a LazyLoadingViolationException immediately.
Model::preventLazyLoading(! $this->app->isProduction());
// Throw an exception if an attribute is accessed that was not loaded in the query SELECT
Model::preventAccessingMissingAttributes(! $this->app->isProduction());
// Throw an exception if an unfillable attribute is passed to Model::create()
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
}
}2. External HTTP Calls & Side Effects Inside Database Transactions
The Problem
Developers frequently wrap entire checkout and registration workflows inside a single DB::transaction() block. This is done with good intentions: if anything fails, roll everything back.
The fatal mistake is placing external network requests (Stripe charge, SMS gateway, external CRM webhook, or synchronous email sending) inside the transaction block.
sequenceDiagram
autonumber
participant App as Laravel App
participant DB as MySQL Database
participant Stripe as Stripe API
App->>DB: BEGIN TRANSACTION
App->>DB: SELECT & UPDATE orders (Row Lock Acquired)
Note over DB: Lock held open!
App->>Stripe: POST /v1/charges (Network Request)
Note over Stripe: 3.5s latency or timeout
Stripe-->>App: 200 OK
App->>DB: UPDATE orders SET status = 'paid'
App->>DB: COMMIT TRANSACTION (Lock Released)Why This Crashes Production
Connection Pool Depletion: Database connections are a scarce resource (often capped at 100–300 connections). When each transaction waits 2–4 seconds for an external API response, concurrent requests stack up and exhaust MySQL/PostgreSQL connection pools.
Row Lock Contention: Any row modified prior to the external call remains locked until the commit. Other requests attempting to read or update those rows hang until they hit
Lock wait timeout exceeded.Orphaned Side Effects on Rollback: If a database query fails after your payment API call or email dispatch, the transaction rolls back the database state—but your user's credit card has already been charged or an erroneous confirmation email has already left your server.
For reliable background notifications and dynamic mail routing that won't compromise database integrity, see our guide to Laravel dynamic SMTP mail configuration.
The Flawed Code (Anti-Pattern)
// app/Services/CheckoutService.php
namespace App\Services;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use App\Mail\OrderPaidMail;
use Stripe\Charge;
class CheckoutService
{
public function processCheckout(User $user, array $cartItems, string $paymentToken): Order
{
return DB::transaction(function () use ($user, $cartItems, $paymentToken) {
// Step 1: Database Write (Locks order and stock tables)
$order = Order::create([
'user_id' => $user->id,
'status' => 'pending',
'amount' => 15000,
]);
// MISTAKE 1: External network call inside DB transaction!
// Network latency holds database lock for several seconds.
$charge = Charge::create([
'amount' => 15000,
'currency' => 'usd',
'source' => $paymentToken,
]);
$order->update([
'status' => 'completed',
'transaction_id' => $charge->id,
]);
// MISTAKE 2: Mail sent inside transaction.
// If DB commit fails on the next line, email is still delivered!
Mail::to($user->email)->send(new OrderPaidMail($order));
return $order;
});
}
}The Refactored Solution (Clean Architectural Pattern)
Keep database transactions micro-scoped. Isolate the external charge, execute database mutations in a fast transaction, and defer external notifications using DB::afterCommit() or queued events.
For enterprise applications managing encrypted payloads and financial transactions, combine this pattern with Laravel AES-256-GCM request encryption and strict REST API design principles.
// app/Actions/ProcessOrderCheckoutAction.php
declare(strict_types=1);
namespace App\Actions;
use App\Events\OrderPaymentCompleted;
use App\Exceptions\PaymentGatewayException;
use App\Models\Order;
use App\Models\User;
use App\Services\PaymentGatewayInterface;
use Illuminate\Support\Facades\DB;
use Throwable;
readonly class ProcessOrderCheckoutAction
{
public function __construct(
private PaymentGatewayInterface $paymentGateway,
) {}
public function execute(User $user, int $amountInCents, string $paymentToken): Order
{
// 1. Authorize / Charge payment OUTSIDE the database transaction
$paymentResult = $this->paymentGateway->charge(
token: $paymentToken,
amount: $amountInCents,
idempotencyKey: 'order_charge_' . $user->id . '_' . time()
);
if (! $paymentResult->isSuccessful()) {
throw new PaymentGatewayException($paymentResult->getErrorMessage());
}
try {
// 2. Micro-scoped transaction: executes in under 5 milliseconds
return DB::transaction(function () use ($user, $amountInCents, $paymentResult): Order {
$order = Order::create([
'user_id' => $user->id,
'status' => 'completed',
'amount' => $amountInCents,
'transaction_id' => $paymentResult->getTransactionId(),
]);
// 3. Defer side-effects until after the transaction has successfully committed
DB::afterCommit(function () use ($order): void {
event(new OrderPaymentCompleted($order));
});
return $order;
});
} catch (Throwable $exception) {
// 4. Compensation logic: if database write fails, refund the external charge
$this->paymentGateway->refund($paymentResult->getTransactionId());
throw $exception;
}
}
}3. Directenv()Helper Calls in Application Code
The Problem
Calling the env() helper inside Controllers, Services, Jobs, or Blade templates is one of the most common pitfalls for developers transitioning from local setups to production CI/CD deployments.
In local development, Laravel reads.env variables on every single request because the configuration is not cached. Everything works as expected.
Once deployed to production, standard performance optimization requires running:
php artisan config:cacheWhy It Breaks
When configuration caching is activated:
Laravel parses all configuration files located in the
config/directory once.It compiles the entire configuration tree into a single cached file at
bootstrap/cache/config.php.Laravel completely bypasses reading the
.envfile for all subsequent HTTP requests.Any
env('KEY')call placed outside of files in theconfig/directory immediately returnsnull.
flowchart LR
A["php artisan config:cache"] --> B["Compiles config/*.php into bootstrap/cache/config.php"]
B --> C["Laravel skips loading .env on requests"]
C --> D["config('services.key') -> Returns Value ✅"]
C --> E["env('SERVICE_KEY') in Controller -> Returns NULL ❌"]The Flawed Code (Anti-Pattern)
// app/Services/SmsNotificationService.php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class SmsNotificationService
{
public function sendVerificationCode(string $phone, string $code): bool
{
// FATAL BUG: Returns NULL in production when config is cached!
$apiKey = env('SMS_GATEWAY_API_KEY');
$endpoint = env('SMS_GATEWAY_ENDPOINT', 'https://api.sms-provider.com/v1');
$response = Http::withHeaders([
'Authorization' => 'Bearer ' . $apiKey, // Authorization: Bearer null
])->post($endpoint . '/messages', [
'to' => $phone,
'body' => 'Your security code is ' . $code,
]);
return $response->successful();
}
}The Refactored Solution
Never access env() directly in domain logic. Always register variables in their dedicated configuration file, define appropriate defaults, and retrieve them via the config() helper with strict type casting.
1. Define the Configuration Key
In config/services.php:
// config/services.php
return [
// Existing services (mailgun, stripe, etc.)...
'sms_gateway' => [
'api_key' => env('SMS_GATEWAY_API_KEY'),
'endpoint' => env('SMS_GATEWAY_ENDPOINT', 'https://api.sms-provider.com/v1'),
'timeout_seconds' => (int) env('SMS_GATEWAY_TIMEOUT', 5),
],
];2. Access viaconfig()with Type Safety
// app/Services/SmsNotificationService.php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Support\Facades\Http;
use InvalidArgumentException;
class SmsNotificationService
{
private string $apiKey;
private string $endpoint;
private int $timeout;
public function __construct()
{
$this->apiKey = (string) config('services.sms_gateway.api_key');
$this->endpoint = (string) config('services.sms_gateway.endpoint');
$this->timeout = (int) config('services.sms_gateway.timeout_seconds', 5);
if ($this->apiKey === '') {
throw new InvalidArgumentException('SMS Gateway API key is not configured.');
}
}
public function sendVerificationCode(string $phone, string $code): bool
{
$response = Http::timeout($this->timeout)
->withToken($this->apiKey)
->post($this->endpoint . '/messages', [
'to' => $phone,
'body' => 'Your security code is: ' . $code,
]);
return $response->successful();
}
}Production Readiness Checklist
Before tagging your next production release, run these automated verification steps across your codebase:
# 1. Verify that your configuration compiles cleanly without errors
php artisan config:cache
# 2. Verify route registrations and cache serialization
php artisan route:cache
# 3. Scan for any illegal env() references outside the config directory
git grep "env(" -- ':!config'
# 4. Verify database migrations run within a transaction where supported
php artisan migrate --pretendIf git grep "env(" -- ':!config'returns any lines in your app/, routes/, or resources/directories, migrate those variables to a config file before deploying.
FAQ
Why should you never use env() directly in Laravel controllers or services?
When you run php artisan config:cache in production, Laravel stops loading the.env file during requests, causing all direct env() calls to return null. Always declare variables in config files and read them using the config() helper.
Why is making API or payment calls inside DB::transaction() dangerous?
Third-party HTTP requests introduce variable network latency. Keeping database transactions open during network calls holds database locks and connection pool slots, leading to connection exhaustion and database lock timeouts under traffic.
How do you detect N+1 query problems before deploying to production?
Add Model::preventLazyLoading(!app()->isProduction()) inside your AppServiceProvider boot method to throw an explicit exception whenever a relation is lazy-loaded in local and testing environments.
What is the best way to handle event dispatching inside database transactions?
Use DB::afterCommit(fn() => event(new OrderCreated($order))) or configure your queued listeners to implement ShouldQueueAfterCommit so side effects only run after data is safely committed.
Next Steps
Review your existing codebase for these three patterns today. Start by adding Model::preventLazyLoading(!app()->isProduction()) to your AppServiceProvider and audit your billing actions to ensure all external API calls are isolated outside of database transaction boundaries.