Search posts, tags, users, and pages
I've been using scopes inside with clauses etc for a very long time, I don't think this is needed. I believe it is not working for you simply because you are not returning the $query object from the scope, it should be:
public function scopeWithCalculatedPricing( $query, $include_tax = false ) {
$tax_multiplier = $include_tax ? 1.2 : 1;
return $query->selectRaw( 'products.*, ( products.price * ' . $tax_multiplier . ' ) as total_price' )
->where( 'stock', '>', 0 );
}
Please note the added return. If you can see if you refer to the laravel docs on the subject: https://laravel.com/docs/5.4/eloquent#local-scopes
Thanks for the suggestion. I will give this a go too and circle back!
Firstly, to be clear: my scope works perfectly with or without the return. It's the ability to use it in a subquery that matters and hopefully this response will explain why.
Also it's worth noting that the 'trick' in this solution actually has very little to do with the scope and is really about Eloquent's very smart use of magic methods and how we can put that to good effect to create terse solutions to interesting problems.
So without further ado, here's what I found:
Adding the return, doesn't solve our problem.
$query from the scope method. It doesn't make much difference overall, it's just a convenience for method chaining. Another great design decision in most parts of Eloquent that we should definitely imitate and I had forgotten to.When calling a scope, we don't call the function name directly, we call an altered version of the name: withCalculatedPricing not scopeWithCalculatedPricing.
Why? Because that's the convention. But also because Eloquent is then able to provide us with some conveniences. Using the magic methods, it creates an instance of our model (in case we called it statically), finds the scope instance method, and passes in a new query builder.
In a fresh call to Product::withCalculatedPricing(), the query builder is very simple and possibly knows nothing about any parent objects or related entities. Even if it did, it wouldn't be the ones referred to by our super-query, Customer::with( ... ).
But for our subquery to work properly and benefit fully from the scope, we need the scope function to know about our current $query, which includes everything about the parent query. This is only available from the variable we define as the first parameter of our callback function, $query. Somehow, we need to pass this into our scope.
$customers = Customer::with( [ 'products' => function ( $query ) {
// $query in here is the *only* thing that has all of the stuff we need to build the correct query
}]);
However, we can't pass $query in using the conventional way of calling the scope, since this does not allow us to pass a query builder instance in. It assumes one will be supplied by the magic method. Notice how the query builder just 'appears' in our scope, evidenced as $parameter_one in the call is translated to $parameter_two in the scope:
// Outside of model
$parameter_one = 'Foo'
Product::withCalculatedPricing( $parameter_one );
// Inside of model
public function scopeWithCalculatedPricing( $query, $parameter_two ) {
// $parameter_two == 'Foo'
}
And we can't call the scope method directly ($product->scopeWithCalculatedCosts()) as it's an instance method and we have no instances of the model we're trying to subquery yet - unless we create a dummy one here now. But this is extra, unnecessary code when Eloquent can do this for us already.
We can't 'apply' the $query returned from our scope either, as this would overwrite our current $query with a new one:
$customers = Customer::with( [ 'products' => function( $query ) {
// This overwrites the subquery, which will give us an incorrect result
$query = Product::withCalculatedPricing( true );
}]);
And we can't even chain it:
$customers = Customer::with( [ 'products' => function( $query ) {
// Essentially chaining one instance onto another $this->$this - impossible
$scope_query = Product::withCalculatedPricing( true );
$query->$scope_query;
}]);
The only way to do this in a subquery is to use a separate function that can receive a query builder object directly. And that's exactly what we do with our protected function withCalculatedPricingScope( $query ) {}.
The reusability comes from pointing our scope method to this function too. I guess that's the real trick. Pretty simple really.
Hope that helps :)
Using the magic methods, it creates an instance of our model (in case we called it statically), finds the scope instance method, and passes in a new query builder.
This is actually very incorrect
An instance of the model is ONLY created when you call a method statically. Then in the case that the method you're calling isn't a method of that model and the method is not increment() or decrement() the model assumes you're referrencing the Eloquent Builder, creates a new query instance which sets the model on the Eloquent Builder instance and then finally calls the method that was called on the model statically on the instance of the new query builder.
But for our subquery to work properly and benefit fully from the scope, we need the scope function to know about our current $query, which includes everything about the parent query.
This is flat out false because the Customer query has already been run and returned before the products query is even called. with() just accessed the builder's with() method which in turn adds it to the Builder's protected $eaderLoad property which doesn't get referenced to query the relations until AFTER the first query is ran in the get() method because that relation is going to need to primary keys returned from the first query to run the second.
In the example provided below, $query is an instance of the Eloqent Builder with reference to the Product model so $query->withCalculatedPricing(true) absolutely works because as I stated above, the withCalculatedPricing() method does not appear on the Builder itself so in turn it checks for a macro, which doesn't exist. Then it checks to see if Product has a method called scopeWithCalculatedPricing and calls that method with 2 parameters passing an instance of itself as the first parameter and true to the second and returns the result which why it is IMPERATIVE you return the query from the scope on the model when you write it, so you are passing back the same instance of the query
$customers = Customer::with( [ 'products' => function ( $query ) {
// $query in here is the *only* thing that has all of the stuff we need to build the correct query
}]);
This is only available from the variable we define as the first parameter of our callback function, $query. Somehow, we need to pass this into our scope.
This is not true at all because when a method doesn't exist on the Builder itself, first the builder prepends the array of parameters passed to it with itself which makes it the first argument passed to any function called from this magic method. Then the builder checks if a macro exists and when it doesn't then the builder checks for a scope on the model and calls it if it exists.
@dbw is correct in stating you need to return the query. For your example, the correct way would be:
public function scopeWithCalculatedPricing( $query, $include_tax = false ) {
$tax_multiplier = $include_tax ? 1.2 : 1;
return $query->selectRaw( 'products.*, ( products.price * ' . $tax_multiplier . ' ) as total_price' )
->where( 'stock', '>', 0 );
}
With the above you can do:
$customers = Customer::with( [ 'products' => function( $query ) {
// You are in the Product query builder which has access to all local scopes on Product
$query->withCalculatedPricing(true);
}]);
Source: I am the author of EloquentFilter
This is actually very incorrect
Well, no, it wasn't incorrect, it just wasn't as specific as you correctly note.
I completely agree with your summation and I'll concede to anyone who knows far more about the inner workings of Eloquent than me. Indeed, that is how I'd hoped things would work.
However, when I tried to get this to work originally I was getting a Object of class Illuminate\Database\Eloquent\Relations\HasMany could not be converted to string error for some reason. I hadn't explored this error to find out what was causing it. But it seems that was because of the lack of a return.
After @dbw 's comment, I added the return and tried again, but I was still getting an error. I can only assume that I attempted this so hastily that I didn't spot another error.
Following your comment, I've tried this code again and I am now getting the desired result.
I hang my head in shame and bow to your greatness. Clearly, I need to fact-check my stories more thoroughly in future!
Thank you for taking the time to write a considered response.
Of course! One of the reasons I love Eloquent is because it is very intuitive and there was a lot of work put in behind the scenes to lower that barrier to entry for those new to Laravel or even PHP in general. If I can give advice to anyone working with Eloquent it's that if the steps you're taking to achieve what you're trying to do seem repetitive or unnecessary, they probably are. If something seems too complicated then you're probably over thinking it.
100% of my development knowledge came from the open source community and people like you that take time out of their schedules to write articles and documentation for people to learn. Keep up what you're doing and know that the lurkers are appreciative of your contributions. Right or wrong way, we learn them all to become better at our craft. In fact, I learn more from every mistake I have made and bug I have introduced than I have from copying and pasting code from github.
Keep up the writing and feel free to reach out to me for any reason.
Cheers!
Agreed. My ego was saying "delete the post and remove all traces", but I think it's actually more valuable to me (and maybe others) if I just leave this here.