Now it's time to take care of the second part of the client's description of the Tours List: filtering and ordering.
priceFrom
, priceTo
, dateFrom
(from that startingDate
) and dateTo
(until that startingDate
).price
asc and desc. They will always be sorted, after every additional user-provided filter, by startingDate
asc.First, let's take care of the filter.
Here's our Controller at the moment:
app/Http/Controllers/Api/V1/TourController.php:
public function index(Travel $travel){ $tours = $travel->tours() ->orderBy('starting_date') ->paginate(); return TourResource::collection($tours);}
And our goal is to process the endpoint like this:
/api/v1/travels/{slug}/tours?priceFrom=123&priceTo=456&dateFrom=2023-06-01&dateTo=2023-07-01
So, we have four possible parameters:
And all of them are optional, so there may be only one of them passed, or all four.
So, our goal is to build a dynamic query based on different request()
parameters.
We will add a Request $request
parameter, which will be auto-resolved by Laravel and contain all the GET parameters.
And then, we will use the Eloquent method when()
, which has two parameters:
true
use Illuminate\Http\Request; class TourController extends Controller{ public function index(Travel $travel, Request $request) { $tours = $travel->tours() ->when($request->priceFrom, function ($query) use ($request) { $query->where('price', '>=', $request->priceFrom * 100); }) ->when($request->priceTo, function ($query) use ($request) { $query->where('price', '<=', $request->priceTo * 100); }) ->when($request->dateFrom, function ($query) use ($request) { $query->where('starting_date', '>=', $request->dateFrom); }) ->when($request->dateTo, function ($query) use ($request) { $query->where('starting_date', '<=', $request->dateTo); }) ->orderBy('starting_date') ->paginate(); return TourResource::collection($tours); }}
You can read more about the when()
method in the official docs or my older tutorial.
Notice: we need to pass the use ($request)
to every callback function if we want to use the $request
variable inside of that function. Otherwise, that function doesn't "know" about that global variable because it's initialized outside of that function, earlier.
Similarly, we will add a when()
condition for additional ordering by price if it's present.
public function index(Travel $travel, Request $request){ $tours = $travel->tours() // ->when($request->priceFrom, ...) // ->when($request->priceTo, ...) // ->when($request->dateFrom, ...) // ->when($request->dateTo, ...) ->when($request->sortBy, function ($query) use ($request) { if (!in_array($request->sortBy, ['price']) || (!in_array($request->sortOrder, ['asc', 'desc']))) { return; } $query->orderBy($request->sortBy, $request->sortOrder); }) ->orderBy('starting_date') ->paginate(); return TourResource::collection($tours);}
In addition to just passing those parameters, we add some validation: if the sorting column is not price
and the sorting direction is not in asc/desc
, then we don't add any sorting at all.
As a result, we will have this in Postman:
As you saw previously, we added validation for price/order "manually", just adding if-statements. But why don't we use Laravel mechanism for validation, with validation rules, instead?
And also, it's our chance to add the validation to other parameters of filtering. Otherwise, here's what would happen if someone tries to pass a string to the price parameter:
As you can see, it's a 500 status code error, which means that something happened on the server side and it's our fault to not validate the data. One of the goals of the API creator is to process the data and return the 4XX status code with useful information to the client on what went wrong and how to fix it.
We do have $request
object, so we can use Laravel $request->validate()
on it, passing the validation rules array. If that validation fails, it will return the 422 HTTP status code, instead of the 500 status code.
use Illuminate\Validation\Rule; // ... public function index(Travel $travel, Request $request){ $request->validate([ 'priceFrom' => 'numeric', 'priceTo' => 'numeric', 'dateFrom' => 'date', 'dateTo' => 'date', 'sortBy' => Rule::in(['price']), 'sortOrder' => Rule::in(['asc', 'desc']), ]); $tours = $travel->tours() // ->when(...) // ->when(...) // ->when(...) // ->when(...) ->when($request->sortBy, function ($query) use ($request) { // We don't need to validate parameters here anymore $query->orderBy($request->sortBy, $request->sortOrder); }) ->orderBy('starting_date') ->paginate(); return TourResource::collection($tours);}
Now if we launch the same request with invalid parameters, we have a proper 422 error:
Great! But even that is not all, we can improve it even more, with better validation messages.
With the Rule::in()
validation method, in case of invalid sortBy/sortOrder
parameters, Laravel will give a generic validation message "The selected XXXXX is invalid":
We can customize it by specifying what are actual valid values to pass. For that, we may add the second parameter to the $request->validate()
method, which is the array of validation messages per field:
$request->validate([ 'priceFrom' => 'numeric', 'priceTo' => 'numeric', 'dateFrom' => 'date', 'dateTo' => 'date', 'sortBy' => Rule::in(['price']), 'sortOrder' => Rule::in(['asc', 'desc']),], [ 'sortBy' => "The 'sortBy' parameter accepts only 'price' value", 'sortOrder' => "The 'sortOrder' parameter accepts only 'asc' and 'desc' values",]);
And now we have a proper clear validation message!
The final step is my personal preference to use Form Requests for validation. And yes, you can use Form Requests even it it's not specifically a "form". And also yes, you can use them on GET requests, too.
php artisan make:request ToursListRequest
Then we pass the validation rules and messages inside that class:
app/Http/Requests/ToursListRequest.php:
namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest;use Illuminate\Validation\Rule; class ToursListRequest extends FormRequest{ /** * Determine if the user is authorized to make this request. */ public function authorize(): bool { return true; } /** * Get the validation rules that apply to the request. * * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string> */ public function rules(): array { return [ 'priceFrom' => 'numeric', 'priceTo' => 'numeric', 'dateFrom' => 'date', 'dateTo' => 'date', 'sortBy' => Rule::in(['price']), 'sortOrder' => Rule::in(['asc', 'desc']), ]; } public function messages(): array { return [ 'sortBy' => "The 'sortBy' parameter accepts only 'price' value", 'sortOrder' => "The 'sortOrder' parameter accepts only 'asc' and 'desc' values", ]; }}
And finally, in the Controller, we just pass the Form Request as a parameter, instead of a general Request
class, and then we don't have any validation inside the method body.
use Illuminate\Http\Request; // [!tl --]use App\Http\Requests\ToursListRequest; // [!tl --] class TourController extends Controller{ public function index(Travel $travel, ToursListRequest $request) { $tours = $travel->tours()->...; return TourResource::collection($tours); }}
This is a good example of "separation of concerns" principle in programming, where each class is responsible for its own purpose: in this case, validation is separated to its dedicated class.
Now we need to add more methods to the ToursListTest
file. I suggest that we take every filter/order parameter and assert that the results are in correct order.
The principle is to create the records in non-standard order and then assert that the actual order with the parameter is correct.
First example - testing that the default starting_date
ordering works well, without any GET
parameters:
tests/Feature/ToursListTest.php:
public function test_tours_list_sorts_by_starting_date_correctly(): void{ $travel = Travel::factory()->create(); $laterTour = Tour::factory()->create([ 'travel_id' => $travel->id, 'starting_date' => now()->addDays(2), 'ending_date' => now()->addDays(3), ]); $earlierTour = Tour::factory()->create([ 'travel_id' => $travel->id, 'starting_date' => now(), 'ending_date' => now()->addDays(1), ]); $response = $this->get('/api/v1/travels/'.$travel->slug.'/tours'); $response->assertStatus(200); $response->assertJsonPath('data.0.id', $earlierTour->id); $response->assertJsonPath('data.1.id', $laterTour->id);}
Variable names here are important: the test should be straightforward for future developers, so names like $earlierTour
or $laterTour
make it understandable what exactly we're testing here.
Similarly, another test method for ordering, with parameters now.
tests/Feature/ToursListTest.php:
public function test_tours_list_sorts_by_price_correctly(): void{ $travel = Travel::factory()->create(); $expensiveTour = Tour::factory()->create([ 'travel_id' => $travel->id, 'price' => 200, ]); $cheapLaterTour = Tour::factory()->create([ 'travel_id' => $travel->id, 'price' => 100, 'starting_date' => now()->addDays(2), 'ending_date' => now()->addDays(3), ]); $cheapEarlierTour = Tour::factory()->create([ 'travel_id' => $travel->id, 'price' => 100, 'starting_date' => now(), 'ending_date' => now()->addDays(1), ]); $response = $this->get('/api/v1/travels/'.$travel->slug.'/tours?sortBy=price&sortOrder=asc'); $response->assertStatus(200); $response->assertJsonPath('data.0.id', $cheapEarlierTour->id); $response->assertJsonPath('data.1.id', $cheapLaterTour->id); $response->assertJsonPath('data.2.id', $expensiveTour->id);}
Here we actually test both ordering parameters: we assert that the cheaper tour is returned before the expensive tour and also the earlier tour before the latest tour.
Again, variable names like $cheapEarlierTour
make it easier to understand.
Now, methods to test the filtering: one method for each parameter, with all possible values, asserting that the endpoint returns what it needs to.
tests/Feature/ToursListTest.php:
public function test_tours_list_filters_by_price_correctly(): void{ $travel = Travel::factory()->create(); $expensiveTour = Tour::factory()->create([ 'travel_id' => $travel->id, 'price' => 200, ]); $cheapTour = Tour::factory()->create([ 'travel_id' => $travel->id, 'price' => 100, ]); $endpoint = '/api/v1/travels/'.$travel->slug.'/tours'; $response = $this->get($endpoint.'?priceFrom=100'); $response->assertJsonCount(2, 'data'); $response->assertJsonFragment(['id' => $cheapTour->id]); $response->assertJsonFragment(['id' => $expensiveTour->id]); $response = $this->get($endpoint.'?priceFrom=150'); $response->assertJsonCount(1, 'data'); $response->assertJsonMissing(['id' => $cheapTour->id]); $response->assertJsonFragment(['id' => $expensiveTour->id]); $response = $this->get($endpoint.'?priceFrom=250'); $response->assertJsonCount(0, 'data'); $response = $this->get($endpoint.'?priceTo=200'); $response->assertJsonCount(2, 'data'); $response->assertJsonFragment(['id' => $cheapTour->id]); $response->assertJsonFragment(['id' => $expensiveTour->id]); $response = $this->get($endpoint.'?priceTo=150'); $response->assertJsonCount(1, 'data'); $response->assertJsonMissing(['id' => $expensiveTour->id]); $response->assertJsonFragment(['id' => $cheapTour->id]); $response = $this->get($endpoint.'?priceTo=50'); $response->assertJsonCount(0, 'data'); $response = $this->get($endpoint.'?priceFrom=150&priceTo=250'); $response->assertJsonCount(1, 'data'); $response->assertJsonMissing(['id' => $cheapTour->id]); $response->assertJsonFragment(['id' => $expensiveTour->id]);} public function test_tours_list_filters_by_starting_date_correctly(): void{ $travel = Travel::factory()->create(); $laterTour = Tour::factory()->create([ 'travel_id' => $travel->id, 'starting_date' => now()->addDays(2), 'ending_date' => now()->addDays(3), ]); $earlierTour = Tour::factory()->create([ 'travel_id' => $travel->id, 'starting_date' => now(), 'ending_date' => now()->addDays(1), ]); $endpoint = '/api/v1/travels/'.$travel->slug.'/tours'; $response = $this->get($endpoint.'?dateFrom='.now()); $response->assertJsonCount(2, 'data'); $response->assertJsonFragment(['id' => $earlierTour->id]); $response->assertJsonFragment(['id' => $laterTour->id]); $response = $this->get($endpoint.'?dateFrom='.now()->addDay()); $response->assertJsonCount(1, 'data'); $response->assertJsonMissing(['id' => $earlierTour->id]); $response->assertJsonFragment(['id' => $laterTour->id]); $response = $this->get($endpoint.'?dateFrom='.now()->addDays(5)); $response->assertJsonCount(0, 'data'); $response = $this->get($endpoint.'?dateTo='.now()->addDays(5)); $response->assertJsonCount(2, 'data'); $response->assertJsonFragment(['id' => $earlierTour->id]); $response->assertJsonFragment(['id' => $laterTour->id]); $response = $this->get($endpoint.'?dateTo='.now()->addDay()); $response->assertJsonCount(1, 'data'); $response->assertJsonMissing(['id' => $laterTour->id]); $response->assertJsonFragment(['id' => $earlierTour->id]); $response = $this->get($endpoint.'?dateTo='.now()->subDay()); $response->assertJsonCount(0, 'data'); $response = $this->get($endpoint.'?dateFrom='.now()->addDay().'&dateTo='.now()->addDays(5)); $response->assertJsonCount(1, 'data'); $response->assertJsonMissing(['id' => $earlierTour->id]); $response->assertJsonFragment(['id' => $laterTour->id]);}
As you can see, we're launching the same endpoint many times with different parameters, so it makes sense to refactor it into a variable $endpoint
. Alternatively, you could also make it a Test Class property and use $this->endpoint
everywhere.
Finally, let's write a quick test to make sure that if we pass invalid parameters, our API would return 422 status code.
tests/Feature/ToursListTest.php:
public function test_tour_list_returns_validation_errors(): void{ $travel = Travel::factory()->create(); $response = $this->getJson('/api/v1/travels/'.$travel->slug.'/tours?dateFrom=abcde'); $response->assertStatus(422); $response = $this->getJson('/api/v1/travels/'.$travel->slug.'/tours?priceFrom=abcde'); $response->assertStatus(422);}
Now, we launch the php artisan test
and... success!
when()
method: Official Docs or My Tutorial
GET
validation: Validating GET Parameters in Laravel: 4 Different Ways