Sign in
Log inSign up
A little trick with Eloquent query scopes that makes them super reusable

A little trick with Eloquent query scopes that makes them super reusable

Simon Hamp's photo
Simon Hamp
·Jan 31, 2017

STOP PRESS

As has been pointed out in the comments, using $query->withCalculatedPricing() should work. If it doesn't, make sure you are returning the Query Builder object from your scope. If you're getting an error or not the results you expect, check your code thoroughly for inconsistencies.

In this light, the following will make interesting reading only if you get a kick out of me monumentally and publicly humiliating myself.


tl;dr - a.k.a. Spoiler Alert

Steps to make reusable query scopes:

  • In your model, create a new protected method that your scope method calls - this MUST be protected
  • When wanting to reuse your scope function on a pre-existing query (e.g. a subquery), call the protected method statically using its full name, not the Eloquent convention for calling scopes (i.e. Product::withCalculatedPricingScope(), not Product::withCalculatedPricing())
  • Sit back and enjoy your reusable scopes

Eloquent query scopes are a great way to add domain-specific language to your models, hiding complexity and encouraging code reuse.

Here's an example:

// In your model
public function scopeWithCalculatedPricing( $query, $include_tax = false ) {
  $tax_multiplier = $include_tax ? 1.2 : 1;
  $query->selectRaw( 'products.*, ( products.price * ' . $tax_multiplier . ' ) as total_price' )
      ->where( 'stock', '>', 0 );
}

// Elsewhere...
// Let's get some product with the total_price calculated with tax
$products_with_prices = Product::withCalculatedPricing( true );

Hopefully, this shows simply how scopes work and can make for neat chunks of reusable and adaptable code.

The problem is that standard scopes created like this can't easily be reused in all of the contexts you may like to use them, e.g. in the subquery used for constraining an eager-loaded relationship.

So today I'd like to show you this neat little trick I use that exposes your scopes for wider use. I haven't come across this anywhere else, so I thought I'd share :)

Setting the scene

Let's say, hypothetically - building on our scope example as defined earlier - we now want to retrieve a collection of all of our customers along with products they've purchased, but filtered and with pricing calculated in a similar way to this scope.

Let's assume, for the sake of this example, that this is part of some functionality for promoting products to customers who had previously purchased that product. We have it in stock, we know they bought one before - let's sell them another one.

In our code, we'd need to constrain an eager load. So here's what our code might look like: (I'm assuming Eloquent being used in Laravel for this example, but this should all mostly work with Eloquent wherever you are)

$customers = Customer::with( [ 'products' => function( $query ) {
  $query->where( ... );
}]);

Straightforward enough so far. But of course we want to stay DRY and reuse our Product model's scope in the subquery - we definitely don't want to rewrite or copy-paste that code here, especially if it needs to stay the same everywhere!

Following are some steps we might try to achieve this, but sadly none of them will work.

Failed attempts to fly

$query

We can't just attach it to the current Query Builder because our scope is in our Product model, not in the Query Builder class.

$customers = Customer::with( [ 'products' => function( $query ) {
    // Attaching the scope to the current Query Builder, $query
    $query->withCalculatedPricing(true);
    // Result: Object of class Illuminate\Database\Eloquent\Relations\HasMany could not be converted to string
}]);
$this

Depending on the context we're creating our callback function in, we can't just use $this->withCalculatedPricing(true) because $this inside of a closure is inherited from the scope the closure is defined in, not the context of the class it might be called inside.

$customers = Customer::with( [ 'products' => function( $query ) {
    // How about using $this?
    $this->withCalculatedPricing(true);
    // Result: Call to undefined method {calling class}::withCalculatedPricing()
}]);
static call

Perhaps then we need to call our Product class as if we're in an external context. In that case, we'd write Product::withCalculatedPricing(true).

$customers = Customer::with( [ 'products' => function( $query ) {
    // Ok what about a conventional scope call?
    Product::withCalculatedPricing(true);
    // Result: Best case, a separate query that has no effect on our subquery.
    // Worst case, fatal errors and QueryExceptions    
}]);

But, while this might 'work', it doesn't give us the result we want. This will create a new query inside our subquery.

So close!

That last attempt is getting really close. We're definitely working with the right object, thanks to Eloquent's magic methods taking part in the process. Sadly this magic doesn't help us out in this case; it actually hinders us.

So to avoid the magic method, our final alternative is to reference the method directly by its full name. Remember though, we can't use $this in our callback function hoping it will be a Product model instance.

Oooh I know, let's call it statically. I'm a genius! Ok, let's see what happens:

$customers = Customer::with( [ 'products' => function( $query ) {
  // Too clever for my own good
    Product::scopeWithCalculatedPricing(true);
}]);

Aaaand we get a Non-static method App\Product::scopeWithCalculatedPricing() should not be called statically error, of course. Damn! So close.

Here's the solution

There is a way though and - plot twist - it actually relies on Eloquent's use of the PHP magic methods. Just when you thought they were getting in the way!

In your model, create a new protected method and move the scope's functionality into it. You should be able to keep the actual functionality identical.

The name of this function doesn't matter, as long as you refer to it consistently (and it doesn't clash with other method names, obvs). I've called it withCalculatedPricingScope() just because it reminds me what it's doing and what it's for without interfering with Eloquent's naming conventions.

Make sure it uses identical parameter requirements to your original scope function so we can use it in exactly the same way.

// Our new protected, scope-like method
protected function withCalculatedPricingScope( $query, $include_tax = false ) {
  $tax_multiplier = $include_tax ? 1.2 : 1;
  $query->selectRaw( 'products.*, ( products.price * ' . $tax_multiplier . ' ) as total_price' )
      ->where( 'stock', '>', 0 );
}

Then call the new protected method from within the original scope method like so, so that our scope behaves the same:

// Our actual scope, which will continue to work in exactly the same way as before
public scopeWithCalculatedPricing( $query, $include_tax = false ) {
  $this->withCalculatedPricingScope( $query, $include_tax );
}

And then elsewhere, we can use our protected method like so:

// But now in our eager loading relationship subquery we can do this:
$customers = Customer::with( [ 'products' => function( $query ) {
    Product::withCalculatedPricingScope( $query, true );
}]);

And guess what, it works! (You're welcome.)

What!? How does this work??

Ok, so you might be thinking "WTF!? How does that even work? This is a protected instance method, that shouldn't even be visible in the scope of our callback function!"

And you'd be absolutely right: it isn't visible. Huh?

It's being exposed via Eloquent's use of the PHP __call() and __callStatic() magic methods. They're one of the essential parts of Eloquent, making it the elegant and powerful ORM you know and love.

These magical functions allow us to call methods that don't even exist in our models (e.g. Product::whereColumn('value')). But they also let us call hidden instance methods on our models statically. The trick is to make your methods protected, allowing the magic methods to take over.

"But why, Sie?"

I thought you'd never ask.

Well, making our new method public will bypass the magic method completely, giving us a Non-static method ... should not be called statically error.

Setting it to private will mean that Eloquent's magic methods (which exist in the parent Model class) can't access the function, so you'll get a Call to undefined method Illuminate\Database\Query\Builder::withCalculatedPricingScope(). Not what we want.

Like the baby bear's porridge, protected is just right.

Static vs Non-static

We could create it as a protected static method instead, but really why would you want to do that? This could introduce other troubles and we lose the ability to use $this inside the method, which could be handy in some cases. So let's stick with it being non-static, shall we?

Wait. Why not just make the standard scope function protected?

Because that will break the scope from being used in the standard fashion: Product::withCalculatedPricing().

If the traditional scope function was protected, we'd have to call it via Product::scopeWithCalculatedPricing() and remember to pass in a $query object as the first parameter, which may not always be possible. In all likelihood, this will result in a 'missing required parameter' error.

That's because the magic methods check for defined functions before scope functions in Eloquent's magic method call stack, meaning our conventionally-named scope function will be ignored as an official scope and treated like a normal instance method.

So let's stick with defining a new scope-like method as if it was an instance method, and using Eloquent's scope naming convention as a wrapper.

That way, we can continue to use our scope exactly the way we always do, Product::withCalculatedPricing().

The only thing to remember though is, when we come to use our reusable scope method, we will need to manually pass a $query variable (a Illuminate\Database\Query\Builder instance) into it.

Of course, in our subquery callback this is automatically made available to us. In that case, it's the actual Illuminate\Database\Query\Builder instance that represents the current subquery. We just need to pass it along. Perfect. But you knew that already... that was the easy part ;)


As you can see, with just a little extra code and a slightly different method call in some circumstances, we have extended the reach of our scopes far and wide.

Hopefully you find this as useful as I did :)

Hassle-free blogging platform that developers and teams love.
  • Docs by Hashnode
    New
  • Blogs
  • AI Markdown Editor
  • GraphQL APIs
  • Open source Starter-kit

© Hashnode 2024 — LinearBytes Inc.

Privacy PolicyTermsCode of Conduct