3 Laravel Mistakes That Break Production (And Fixes)

Avatar
M

Maksudur Rahman

Software Engineer

259Views
5mRead
0Reactions

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:

  1. The N+1 Query Cascade: Uncontrolled relationship hydration and missing eager loading constraints that exhaust database connection pools and memory.

  2. 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.

  3. Direct env() Helper Calls Outside Config Files: Reading environment variables directly in application code, which turn into null when running php 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, preventLazyLoading()

2. Network I/O in DB Transactions

Deadlocks, connection exhaustion, duplicate charges

Holding database row locks during external HTTP latency

Micro-scoped transactions +DB::afterCommit() dispatch

3. Direct env() Calls in App Code

Silent API authentication errors, null credentials

config:cache disables dynamic.env file parsing

Centralized config files + typed config() retrieval


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:

  1. Eager load known relationships using with().

  2. Push aggregation (sums, counts) into the database query rather than computing it in PHP collections.

  3. 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

  1. 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.

  2. 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.

  3. 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:cache

Why It Breaks

When configuration caching is activated:

  1. Laravel parses all configuration files located in the config/directory once.

  2. It compiles the entire configuration tree into a single cached file at bootstrap/cache/config.php.

  3. Laravel completely bypasses reading the.env file for all subsequent HTTP requests.

  4. Any env('KEY') call placed outside of files in the config/directory immediately returns null.

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 --pretend

If 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.

Recommended Resources & Courses

React to this article