Laravel Magic: Cool Until It Tanks Your App’s Performance

Laravel’s “magic” is one of the reasons people love it, why I came to love it. Need a table name? Laravel already knows it. Need a relationship? Laravel already figures it out. Need a something asynchronous? Laravel just works.

But here’s the thing: all that knowing (guessing) comes at a cost. And as your app grows, that cost adds up fast - we’re talking slow queries, bloated memory usage, and queues that turn into a bottlenecked nightmare.

Let’s break down some of the worst offenders and how to fix them before the Voodoo gets you.

1. Guessing Relationship Names

Laravel is really eager to guess your relationship names. It’s so eager that it calls debug_backtrace() every single time to figure it out.

And guess what? debug_backtrace() is slow as fk.**

If you’re relying on Laravel’s default relationship guessing, you’re paying for unnecessary stack traces every time a model figures out a relationship. Multiply that by dozens (or hundreds) of models per request, and congrats - you just torched your app’s performance.

🚨 The Fix: Stop Letting Laravel Guess

Define your damn relationship names explicitly.

class Order extends Model {
    public function user(): BelongsTo {
        return $this->belongsTo(User::class, 'user_id', 'id', 'user'); // Explicit, no guessing
    }
}

Now Laravel doesn’t have to trace back the call stack like a detective. It just knows what to do. Your CPU will thank you. Yes, it's not the fun and easy Laravel way, where we only need the bare minimum and shit works but hey, who is the one reading an article about performance killers in Laravel at the moment (not me). To ensure that every developer on your team always remembers to fill all the parameters you can simply override the guessing methods:

class BaseModel extends Model {
    protected function guessBelongsToManyRelation()
    {
        throw new \LogicException('Guessing Relation names is forbidden!');
    }

    protected function guessBelongsToRelation()
    {
        throw new \LogicException('Guessing Relation names is forbidden!');
    }
}

2. Table Name Guessing

By default, Laravel pluralizes class names to figure out table names. Sounds nice, right? Well, not when it’s doing it for every single model, every single request, and every single relationship. That’s a lot of unnecessary processing just to slap an “s” on a class name. The same goes for N-to-N relation Table names, no pluralization but still a lot of string processing.

🚨 The Fix: Stop Letting Laravel Guess

class User extends Model {
    protected $table = 'users';

    public function roles(): BelongsToMany {
        return $this->belongsToMany(Role::class, 'role_user', 'user_id', 'role_id', 'id', 'id', 'roles'); // Explicit, no guessing
    }
}

And again, to ensure no one forgets to define all the required parameters or properties, override the guessing methods:

class BaseModel extends Model {
    public function joiningTable($related, $instance = null)
    {
        throw new \LogicException('Guessing Table names is forbidden!');
    }

    protected function getTable() {
        if ($this->table === null) {
            throw new \LogicException('Guessing Table names is forbidden!');
        }
        return $this->table;
    }
}

Now, when some poor developer (probably future you) forgets to define a table name, you will notice immediately instead of silently slowing everything down.

3. The default Queue - A Bottleneck waiting to bottleneck

You are using Laravel, you are using queueing, works like a charm, you have your image-processing queue, your order-processing queue and many more (maybe even 20 more) and that's awesome! And yet, there is still that default queue, it does stuff, but what? Every now and again you find a Job in your Repository, which was missing a queue name, but there is still more stuff…

Everybody did this at one point. An api calls slows down to a halt in a production, you do some debugging and discover the culprit: An event listener which loops over way to many database entries. Maybe the admin users get notified when a certain event is triggered, but this particular client has 150 admins and that's way to many to notify synchronous during the event dispatch. But, the fix is super easy-just slap implements ShouldQueue on the event listener, and boom, it’s queued.

✨ Problem solved - The end ✨

The next problem? Every queued listener gets dumped into Laravel’s default queue, creating a giant mess where all kind of jobs, listeners and notifications get processed. The solution, Laravel gives you magic methods like viaQueue() and viaConnection(), which are great, but why are they optional, and why is there no Interface for them? Only Taylor knows.

🚨 The Fix: Force Explicit Queue Naming

Instead of relying on Laravel’s defaults, force every queued listener to explicitly define its queue. Create a base class for your event listeners:

abstract class BaseQueuedListener implements ShouldQueue {
   // Add other magic methods like withDelay or viaConnection
   abstract public function viaQueue(): string;
}

Now, every event listener must define a queue name, even if it’s just "default".

class OrderPlacedListener extends BaseQueuedListener {
    public function viaQueue(): string {
        return 'orders';
    }
}

This keeps your queues organized and prevents slow listeners from accidentally clogging up your entire system. You just have to remember to use it. Since there is no Laravel Base Listener or Job this is one to remember.

Almost the same goes for notifications:

class BasicQueuedNotification extends Notification implements ShouldQueue {
   // Add other magic methods like withDelay, viaConnections or middleware
   abstract public function viaQueues(): array;
}

class OrderPlacedNotification extends BasicQueuedNotification {
    public function viaQueues(): array {
        return [
            // channel => queue-name
            'mail' => 'mail-processing-queue',
        ];
    }
}

4. Collections

Laravel’s collections are powerful, but misusing them can lead to growing performance losses.

toArray() vs all()

Most developers I ask think of these two as aliases, but that's sooo far off from the truth, If you are like me, you prefer to receive and return arrays in your "Business Logic classes". But, I admit, I do like the comfort Collections grant me, which leads to a lot of this:

return collect($someArray)->filter('some_filter_func')->map('some_map_func')->toArray();

Which looks totally fine doesn't it? A nice little one-liner, But tell me, how many loops do we have? The answer is 3, filter() loops over the whole $someArray and map() loops over that result and toArray() loops over the mapped result. Checking - every - single - entry if it is Arrayable:

public function toArray()
{
    return $this->map(fn ($value) => $value instanceof Arrayable ? $value->toArray() : $value)->all();
}

And then it just calls all()!! If you’re dealing with large datasets, using the wrong method can result in massive performance loss.

🚨 The Fix: Be Intentional

If you really need the propagation of the toArray() call, use it, it's fine, but if you just need a god-damn array, use all(). Simple, but ignoring this can lead to headaches down the road.

Method Chaining

You have a view, which shows all the users, which are admins: (Lets pretend this is a good example, of course you should already filter this in your database, but I didn't come up with a better on while writing this)

return collect($allUsers)->filter(static fn(User $user): bool => $user->is_admin)->all();
// or even "cleaner"
return collect($allUsers)->where('is_admin', '===', true)->all(); // Again, as always fill out all the parameters

Now, the view gets a little change, it should only show users which have been active in the last week or something like that.

Easy, just chain another where() on there, no big deal,

return collect($allUsers)->where('is_admin', '===', true)->where('last_active', '>', now()->subWeek())->all(); // Again, as always fill out all the parameters

Done! In less than 50 characters! What an easy job we all have! Well, do that 3 more times, scale up to some 100s of admins and wait a long while for your precious little Dashboard (Yes, it's a dashboard now, no I am not going back and changing the first few mentions of the ominous "view")

🚨 The Fix: take the more verbose route

If we had stuck to the first implementation utilizing filter() call our solution might have looked like this:

$lastWeek = now()->subWeek();
return collect($allUsers)->filter(static fn(User $user): bool => $user->is_admin && $user->last_active > $lastWeek)->all();

Yes, its another line of code and its almost 20 character more than the previous solution! Yes, but it's still just a single loop! And it will stay a single loop even if you slap on 3 more conditions for that fancy little dashboard!

5. Lazy Loading: The Silent Performance Killer

Laravel makes it stupidly easy to lazy load relationships:

// yes we are not using Carbon here, the database know what day it is
$orders = Order::query()
    ->whereToday('created_at', DB::raw('CURDATE()'))
    ->get()
    ->each(static fn(Order $order) => echo $order->user->name); // Lazy loading triggers another query for every

Lets say we have 5 Orders, which results in 6 Queries. Seems harmless? It’s not.

// yes we are not using Carbon here, the database know what day it is
$orders = Order::query()
    ->whereDate('created_at', '>=', DB::raw('CURDATE()'))
    ->get()
    ->each(static fn(Order $order) => echo $order->shop->settings->responsibleAdmin->email; // Lazy loading triggers three more queries

If you loop over multiple models like this, you end up with N+1 (16 in this case) queries, where every iteration fires off another query. Your database is crying, but you won’t realize it until your app starts lagging.

🚨 The Fix: Kill Lazy Loading in dev

The QueryBuilder provides an easy way to eager load relations if you need them.

// yes we are not using Carbon here, the database know what day it is
$orders = Order::query()
    ->with(['shop.settings.responsibleAdmin'])
    ->whereDate('created_at', '>=',  DB::raw('CURDATE()'))
    ->get()
    ->each(static fn(Order $order) => echo $order->shop->settings->responsibleAdmin->email; // No more Queries

Now its only 4 Queries, no Matter how many Orders we get (and we want more!) To ensure you'll never have to deal with that in production ever again, add this to your AppServiceProvider so Laravel throws an exception anytime you accidentally lazy load in development.

use Illuminate\Database\Eloquent\Model;

public function boot() {
    Model::preventLazyLoading();
    if ($this->app->environment() === 'production') {
        Model::handleLazyLoadingViolationUsing(static fn(Model $m, string $key) => report(new LazyLoadingViolationException($m, $key)));
    }
}

As a Bonus, you can customize the LazyLoadingViolation Handler and just report it in production. This is a great way to detect LazyLoading in an app which has been around for a while (trust me).

Edit:

while writing this Taylor dropped new QueryBuilder Date helpers:

$orders = Order::query()->whereToday('created_at')->get();

And it uses fucking CARBON, I was about to remove my snarky comment but no it fucking stays.

TL;DR: Laravel’s Magic Will Betray You

Laravel feels great at first, but once your app scales, it's magic becomes a performance liability, you have to understand the inner works of this framework, understand the magic, known the tricks by heart.

  • ✔️ Define relationships explicitly - don’t let Laravel guess.
  • ✔️ Stop table name guessing - override it and enforce explicit names.
  • ✔️ Don’t dump everything into the default queue - force explicit queue names.
  • ✔️ Know the difference between toArray() and all() - don't play looping Loui
  • ✔️ Prevent lazy loading in development - catch N+1 problems early.

Follow these, and your Laravel app will stay fast, scalable, and not a performance dumpster fire. Ignore them, and you’ll be debugging slow api calls at 3 AM, wondering why you ever trusted Laravel’s magic.