DEV Community

Cover image for 10 Laravel Edge Cases Every Developer Should Know (Before They Hit Production)
kalam714
kalam714

Posted on

10 Laravel Edge Cases Every Developer Should Know (Before They Hit Production)

The 3 AM Wake-Up Call

Picture this: It's Friday evening. You've just deployed your Laravel app to production. Everything tested perfectly on your local machine. Your staging environment? Flawless. You close your laptop, proud of a week well done.

Then, at 3 AM, your phone buzzes. The app is throwing 500 errors. Users can't log in. Orders are failing. Your heart races as you scramble to your laptop, wondering: "It worked perfectly in development... what happened?"

Welcome to the world of edge cases.

Edge cases are those sneaky, boundary-condition scenarios that slip through your testing: the user who enters a 10,000-character bio, the API request with a missing header, the queue job trying to process a deleted model. They're rare enough to miss in development but common enough to wreak havoc in production with real users.

The difference between a junior developer and a seasoned Laravel engineer isn't just knowing the framework—it's anticipating what can go wrong and building defenses before deployment. Today, we're diving into 10 critical Laravel edge cases that every developer should master. Each one comes with real code examples, the nightmare scenario it causes, and how to fix it properly.

Let's make sure your next deployment doesn't come with a 3 AM wake-up call.


1. Validation: The Empty String Paradox

The Scenario

You need a user's phone number to be unique in your database. Simple, right? Add a unique validation rule and you're done.

// UserController.php
public function store(Request $request)
{
    $validated = $request->validate([
        'name' => 'required|string|max:255',
        'email' => 'required|email|unique:users',
        'phone' => 'nullable|unique:users',
    ]);

    User::create($validated);
}
Enter fullscreen mode Exit fullscreen mode

What Goes Wrong

Edge Case: Multiple users submit the form without entering a phone number. Laravel treats empty strings ("") as valid values. Your database now has multiple rows with phone = "", violating your business logic expectation of uniqueness.

The Fix

Always use nullable with a database-level constraint, or better yet, explicitly handle empty values:

public function store(Request $request)
{
    $validated = $request->validate([
        'name' => 'required|string|max:255',
        'email' => 'required|email|unique:users',
        'phone' => 'nullable|string|unique:users',
    ]);

    // Convert empty strings to null
    $validated['phone'] = $validated['phone'] ?: null;

    User::create($validated);
}
Enter fullscreen mode Exit fullscreen mode

Better yet, use a custom rule or middleware to sanitize input:

// In AppServiceProvider or middleware
$request->merge([
    'phone' => $request->phone ?: null
]);
Enter fullscreen mode Exit fullscreen mode

Pro Tip: Set database columns to NULL by default for optional fields, never empty strings. Update your migration:

$table->string('phone')->nullable()->unique();
Enter fullscreen mode Exit fullscreen mode

2. Eloquent Models: The Null Surprise

The Scenario

You're building a blog. A user clicks on a post link, and you fetch it by ID.

// PostController.php
public function show($id)
{
    $post = Post::find($id);
    return view('posts.show', compact('post'));
}
Enter fullscreen mode Exit fullscreen mode

What Goes Wrong

Edge Case: Someone manually types /posts/99999 in the URL bar—an ID that doesn't exist. Post::find() returns null, and your Blade template explodes trying to access $post->title.

Trying to get property 'title' of non-object
Enter fullscreen mode Exit fullscreen mode

The Fix

Option 1: Use findOrFail() (Recommended)

public function show($id)
{
    $post = Post::findOrFail($id); // Throws 404 automatically
    return view('posts.show', compact('post'));
}
Enter fullscreen mode Exit fullscreen mode

Option 2: Manual null check

public function show($id)
{
    $post = Post::find($id);

    if (!$post) {
        abort(404, 'Post not found');
    }

    return view('posts.show', compact('post'));
}
Enter fullscreen mode Exit fullscreen mode

Pro Tip: Use route model binding for even cleaner code:

// routes/web.php
Route::get('/posts/{post}', [PostController::class, 'show']);

// PostController.php
public function show(Post $post) // Laravel auto-fetches or 404s
{
    return view('posts.show', compact('post'));
}
Enter fullscreen mode Exit fullscreen mode

3. Database: Mass Assignment Vulnerabilities

The Scenario

You're building a user registration system. To save time, you pass the entire request to create().

public function register(Request $request)
{
    $user = User::create($request->all());
    return response()->json($user);
}
Enter fullscreen mode Exit fullscreen mode

What Goes Wrong

Edge Case: A malicious user inspects your form, adds a hidden field <input name="is_admin" value="1">, and submits. Suddenly, they've granted themselves admin privileges.

POST /register
{
    "name": "Hacker",
    "email": "hacker@example.com",
    "password": "password",
    "is_admin": 1
}
Enter fullscreen mode Exit fullscreen mode

The Fix

Always use $fillable or $guarded in your models:

// User.php
class User extends Authenticatable
{
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    // Or use $guarded to block specific fields
    protected $guarded = [
        'is_admin',
        'role',
        'is_verified',
    ];
}
Enter fullscreen mode Exit fullscreen mode

And explicitly validate only what you need:

public function register(Request $request)
{
    $validated = $request->validate([
        'name' => 'required|string|max:255',
        'email' => 'required|email|unique:users',
        'password' => 'required|min:8|confirmed',
    ]);

    $validated['password'] = Hash::make($validated['password']);
    $user = User::create($validated);

    return response()->json($user);
}
Enter fullscreen mode Exit fullscreen mode

4. API Endpoints: The Missing Parameter Minefield

The Scenario

Your API expects pagination parameters to filter products.

// ProductController.php
public function index(Request $request)
{
    $perPage = $request->input('per_page');
    $page = $request->input('page');

    $products = Product::paginate($perPage);
    return response()->json($products);
}
Enter fullscreen mode Exit fullscreen mode

What Goes Wrong

Edge Case 1: Client sends per_page=0 → Database error.

Edge Case 2: Client sends per_page=999999 → Server runs out of memory.

Edge Case 3: Client sends per_page=abc → Type error.

The Fix

Validate and constrain all inputs:

public function index(Request $request)
{
    $validated = $request->validate([
        'per_page' => 'nullable|integer|min:1|max:100',
        'page' => 'nullable|integer|min:1',
        'sort' => 'nullable|in:name,price,created_at',
    ]);

    $perPage = $validated['per_page'] ?? 15; // Default fallback

    $products = Product::orderBy($validated['sort'] ?? 'created_at', 'desc')
                       ->paginate($perPage);

    return response()->json($products);
}
Enter fullscreen mode Exit fullscreen mode

Pro Tip: Create a reusable validation rule:

// app/Rules/ValidPagination.php
class ValidPagination implements Rule
{
    public function passes($attribute, $value)
    {
        return is_numeric($value) && $value >= 1 && $value <= 100;
    }
}

// Controller
'per_page' => ['nullable', new ValidPagination],
Enter fullscreen mode Exit fullscreen mode

5. Authentication & Middleware: The Phantom User

The Scenario

You're building a dashboard that displays the logged-in user's profile.

// DashboardController.php
public function index()
{
    $user = Auth::user();
    $stats = [
        'posts' => $user->posts()->count(),
        'followers' => $user->followers()->count(),
    ];

    return view('dashboard', compact('user', 'stats'));
}
Enter fullscreen mode Exit fullscreen mode

What Goes Wrong

Edge Case: A user's session expires while they're still on the site. They click a link, and suddenly Auth::user() returns null. Your code crashes trying to call methods on null.

The Fix

Option 1: Use auth middleware properly

// routes/web.php
Route::get('/dashboard', [DashboardController::class, 'index'])
     ->middleware('auth'); // Redirects to login if not authenticated
Enter fullscreen mode Exit fullscreen mode

Option 2: Defensive null checks

public function index()
{
    $user = Auth::user();

    if (!$user) {
        return redirect()->route('login')
                        ->with('error', 'Please log in to continue.');
    }

    $stats = [
        'posts' => $user->posts()->count(),
        'followers' => $user->followers()->count(),
    ];

    return view('dashboard', compact('user', 'stats'));
}
Enter fullscreen mode Exit fullscreen mode

Option 3: Use optional helper (Laravel 8+)

$postCount = optional(Auth::user())->posts()->count() ?? 0;
Enter fullscreen mode Exit fullscreen mode

6. File Uploads: The Disk Space Disaster

The Scenario

You allow users to upload profile pictures.

// ProfileController.php
public function updateAvatar(Request $request)
{
    $path = $request->file('avatar')->store('avatars', 'public');

    auth()->user()->update(['avatar' => $path]);

    return back()->with('success', 'Avatar updated!');
}
Enter fullscreen mode Exit fullscreen mode

What Goes Wrong

Edge Case 1: User doesn't upload a file → Call to a member function store() on null.

Edge Case 2: User uploads a 50MB video instead of an image.

Edge Case 3: Disk runs out of space.

Edge Case 4: Configured disk doesn't exist in config/filesystems.php.

The Fix

public function updateAvatar(Request $request)
{
    $request->validate([
        'avatar' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048', // 2MB max
    ]);

    try {
        if ($request->hasFile('avatar') && $request->file('avatar')->isValid()) {
            // Delete old avatar
            if (auth()->user()->avatar) {
                Storage::disk('public')->delete(auth()->user()->avatar);
            }

            $path = $request->file('avatar')->store('avatars', 'public');
            auth()->user()->update(['avatar' => $path]);

            return back()->with('success', 'Avatar updated!');
        }
    } catch (\Exception $e) {
        Log::error('Avatar upload failed: ' . $e->getMessage());
        return back()->with('error', 'Upload failed. Please try again.');
    }

    return back()->with('error', 'Invalid file.');
}
Enter fullscreen mode Exit fullscreen mode

Pro Tip: Add server-level protections in php.ini:

upload_max_filesize = 2M
post_max_size = 2M
Enter fullscreen mode Exit fullscreen mode

7. Date/Time Handling: The Timezone Trap

The Scenario

You're building an event booking system. Users can schedule appointments.

// EventController.php
public function store(Request $request)
{
    Event::create([
        'title' => $request->title,
        'starts_at' => $request->starts_at, // "2025-10-15 14:00"
    ]);
}
Enter fullscreen mode Exit fullscreen mode

What Goes Wrong

Edge Case 1: User in Tokyo books an event. Your server is in New York. Time gets stored incorrectly.

Edge Case 2: User sends date as "10/15/2025" instead of ISO format → SQL error.

Edge Case 3: Daylight Saving Time causes off-by-one-hour bugs.

The Fix

Always use Carbon and validate formats:

use Carbon\Carbon;

public function store(Request $request)
{
    $validated = $request->validate([
        'title' => 'required|string|max:255',
        'starts_at' => 'required|date_format:Y-m-d H:i:s',
        'timezone' => 'required|timezone',
    ]);

    // Convert user's timezone to UTC for storage
    $startsAt = Carbon::parse($validated['starts_at'], $validated['timezone'])
                      ->setTimezone('UTC');

    Event::create([
        'title' => $validated['title'],
        'starts_at' => $startsAt,
        'timezone' => $validated['timezone'],
    ]);
}
Enter fullscreen mode Exit fullscreen mode

In your model, cast to Carbon:

// Event.php
protected $casts = [
    'starts_at' => 'datetime',
];
Enter fullscreen mode Exit fullscreen mode

Display in user's timezone:

// In Blade or API response
$event->starts_at->setTimezone($user->timezone)->format('M d, Y g:i A');
Enter fullscreen mode Exit fullscreen mode

8. Queue Jobs: The Vanishing Model

The Scenario

You dispatch a job to send a welcome email after user registration.

// RegisterController.php
public function register(Request $request)
{
    $user = User::create($validated);

    SendWelcomeEmail::dispatch($user);

    return redirect('/dashboard');
}
Enter fullscreen mode Exit fullscreen mode
// app/Jobs/SendWelcomeEmail.php
class SendWelcomeEmail implements ShouldQueue
{
    public $user;

    public function __construct(User $user)
    {
        $this->user = $user;
    }

    public function handle()
    {
        Mail::to($this->user->email)->send(new WelcomeMail($this->user));
    }
}
Enter fullscreen mode Exit fullscreen mode

What Goes Wrong

Edge Case 1: Admin deletes the user before the job runs → Job fails with "Model not found".

Edge Case 2: User model changes (new attributes added) between job dispatch and execution → Serialization mismatch.

Edge Case 3: Database connection fails → Job crashes without retry.

The Fix

Pass IDs, not models:

// RegisterController.php
SendWelcomeEmail::dispatch($user->id);

// SendWelcomeEmail.php
class SendWelcomeEmail implements ShouldQueue
{
    public $userId;
    public $tries = 3; // Retry 3 times
    public $timeout = 60;

    public function __construct($userId)
    {
        $this->userId = $userId;
    }

    public function handle()
    {
        $user = User::find($this->userId);

        // Handle deleted user gracefully
        if (!$user) {
            Log::warning("User {$this->userId} not found for welcome email");
            return;
        }

        Mail::to($user->email)->send(new WelcomeMail($user));
    }

    public function failed(\Throwable $exception)
    {
        Log::error("Failed to send welcome email: " . $exception->getMessage());
    }
}
Enter fullscreen mode Exit fullscreen mode

Pro Tip: Use SerializesModels trait carefully—it re-fetches models, but can fail if deleted.


9. Seeders & Factories: The Unique Constraint Collision

The Scenario

You're seeding test data for your users table.

// database/seeders/UserSeeder.php
public function run()
{
    User::factory()->count(100)->create();
}
Enter fullscreen mode Exit fullscreen mode
// database/factories/UserFactory.php
public function definition()
{
    return [
        'name' => $this->faker->name(),
        'email' => $this->faker->email(),
        'username' => $this->faker->userName(),
    ];
}
Enter fullscreen mode Exit fullscreen mode

What Goes Wrong

Edge Case: Faker occasionally generates duplicate usernames or emails → Seeder crashes with "Duplicate entry" error after creating 47 users. Your test database is now in an inconsistent state.

The Fix

Use unique() modifier in factories:

public function definition()
{
    return [
        'name' => $this->faker->name(),
        'email' => $this->faker->unique()->safeEmail(),
        'username' => $this->faker->unique()->userName(),
        'phone' => $this->faker->unique()->phoneNumber(),
    ];
}
Enter fullscreen mode Exit fullscreen mode

Handle failures gracefully in seeders:

public function run()
{
    try {
        User::factory()->count(100)->create();
    } catch (\Exception $e) {
        $this->command->error("Seeding failed: " . $e->getMessage());

        // Rollback or clean up
        DB::table('users')->truncate();
        throw $e;
    }
}
Enter fullscreen mode Exit fullscreen mode

Pro Tip: Use database transactions in seeders:

public function run()
{
    DB::transaction(function () {
        User::factory()->count(100)->create();
        Post::factory()->count(500)->create();
    });
}
Enter fullscreen mode Exit fullscreen mode

10. Blade Templates: The Undefined Relationship Error

The Scenario

You're displaying a user's posts on their profile page.

// ProfileController.php
public function show(User $user)
{
    return view('profile.show', compact('user'));
}
Enter fullscreen mode Exit fullscreen mode
<!-- resources/views/profile/show.blade.php -->
<h1>{{ $user->name }}'s Profile</h1>

<h2>Recent Posts</h2>
@foreach($user->posts as $post)
    <article>
        <h3>{{ $post->title }}</h3>
        <p>By {{ $post->author->name }}</p>
    </article>
@endforeach
Enter fullscreen mode Exit fullscreen mode

What Goes Wrong

Edge Case 1: User has no posts → $user->posts is an empty collection, loop doesn't run (fine), but no feedback to user.

Edge Case 2: A post's author_id is null or points to a deleted user → Call to a member function 'name' on null.

Edge Case 3: N+1 query problem → 50 posts = 50 additional database queries.

The Fix

Always check for empty relationships and use eager loading:

// ProfileController.php
public function show(User $user)
{
    $user->load(['posts' => function ($query) {
        $query->with('author')->latest()->limit(10);
    }]);

    return view('profile.show', compact('user'));
}
Enter fullscreen mode Exit fullscreen mode
<!-- profile/show.blade.php -->
<h1>{{ $user->name }}'s Profile</h1>

<h2>Recent Posts</h2>
@forelse($user->posts as $post)
    <article>
        <h3>{{ $post->title }}</h3>
        <p>By {{ $post->author->name ?? 'Unknown Author' }}</p>
    </article>
@empty
    <p>No posts yet. <a href="/posts/create">Create your first post!</a></p>
@endforelse
Enter fullscreen mode Exit fullscreen mode

Use optional() helper for nested relationships:

<p>By {{ optional($post->author)->name ?? 'Anonymous' }}</p>
Enter fullscreen mode Exit fullscreen mode

Edge Case Cheat Sheet

Edge Case Quick Fix
Empty string validation Use nullable + convert "" to null
Null Eloquent model Use findOrFail() or route model binding
Mass assignment Define $fillable or $guarded in models
Invalid API params Validate with min, max, in rules
Null Auth::user() Use auth middleware or null checks
File upload errors Validate file type, size, and existence
Timezone issues Store in UTC, convert with Carbon
Deleted queue models Pass IDs, handle missing models gracefully
Duplicate seeder data Use unique() in factories
Undefined relationships Use @forelse, optional(), eager loading

The Edge Case Mindset

Here's the truth: Your app will encounter edge cases in production. The question isn't if, but when—and whether you've prepared for them.

Handling edge cases isn't about being paranoid. It's about being professional. It's the difference between code that works "most of the time" and code that earns user trust. It's the difference between a developer who ships features and a developer who ships reliable features.

Every validation rule you write, every null check you add, every try-catch block you implement—these aren't signs of overthinking. They're signs of craftsmanship. They're the invisible layer of protection that lets users sleep soundly while your app runs flawlessly at 3 AM.

The best Laravel developers don't just write code that works. They write code that survives the unexpected. They anticipate the edge cases, handle them gracefully, and build applications that degrade elegantly under pressure.

So before you push to production, ask yourself: "What could possibly go wrong?" Then make sure the answer is: "I've already handled it."

A Laravel app that survives edge cases is an app users trust and developers love.

Now go forth and build something bulletproof. 🚀

Top comments (0)