Back to Course |
Laravel Travel Agency API From Scratch

Tours Filtering and Ordering

Now it's time to take care of the second part of the client's description of the Tours List: filtering and ordering.


Task Description From Client

  1. Users can filter (search) the results by priceFrom, priceTo, dateFrom (from that startingDate) and dateTo (until that startingDate).
  2. User can sort the list by price asc and desc. They will always be sorted, after every additional user-provided filter, by startingDate asc.

Filtering Tours: Eloquent when()

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:

  • priceFrom
  • priceTo
  • dateFrom
  • dateTo

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:

  • a condition
  • and how to transform the query in case of that condition is 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.


Sorting By Price with Validation

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:


Refactor Validation into Form Request

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.


Automated Tests for Sorting

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.


Automated Tests for Filtering

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.


Automated Tests for Validation

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!


GitHub commit for this lesson:


Links to read/watch more: