phplaravellaravel-livewire

Reusable sorting & pagination logic for multiple Livewire components. Livewire 3


Goal: Create reusable sorting and pagination logic for multiple Livewire components.

Description of the problem:

RecipeList.php is responsible for filtering recipes based on URI parameters, as well as sorting and paginating them.

After creating RecipeList.php, I needed to create 2 more components to display lists of recipes for liked & saved recipes. Each recipe list should have sorting and pagination logic, and I'm wondering: is it possible to reuse the sorting and pagination logic to avoid repetition in these three components?

Which logic do the components have in common?

What's different about the components?

Code:

RecipeList.php (short version):

class RecipeList extends Component
{
    public $sort = 'popularity';

    public $dish_category = '';
    public $dish_subcategory = '';
    public $cuisine = '';
    public $menu = '';

    protected $queryString = [
        'dish_category',
        'dish_subcategory',
        'cuisine',
        'menu',
    ];

    public function mount(RecipeFilterRequest $request): void
    {
        // mount component
    }

    public function render()
    {
        $recipes = $this->getFilteredRecipes();
        return view('livewire.recipe-list', ['recipes' => $recipes]);
    }

    public function getFilteredRecipes(): LengthAwarePaginator
    {
        // Filter logic using URL parameters
        $query = Recipe::with('...');

        // Sort filtered recipes using $this->sort
        if ($this->sort == 'popularity') {
            $query->orderByDesc('likesCount');
        } elseif {
            // order logic for newest & oldest
           // ...
        }

        return $query->paginate(3);
    }
}

recipe-list.blade.php:

{{-- Dropdown filter --}}
<span>Sort by:</span>
<select name="sorting" wire:model.live="sort">
    <option value="popularity">By Popularity</option>
    <option value="newest">Newest</option>
    <option value="oldest">Oldest</option>
</select>

{{-- Pagination --}}
<div>
    {{ $recipes->links(data: ['scrollTo' => false]) }}
</div>

{{-- Recipes--}}
@forelse($recipes as $recipe)
    <div wire:key="recipe-{{ $recipe->id }}">
        <x-recipe-card :recipe="$recipe"/>
    </div>
@empty
    <span>empty</span>
@endforelse

What have I tried:

I have created Trait HasSortingAndPagination.php:

trait HasSortingAndPagination
{
    // Default sorting parameter
    public string $sort = 'popularity';

    // Sorting logic
    public function applySorting(Builder $query): Builder
    {
       return match ($this->sort){
           'popularity' => $query
               ->orderByDesc('savedCount')
               ->orderByDesc('likesCount')
               ->orderBy('dislikesCount')
               ->orderByDesc('created_at'),
           'newest' => $query->orderByDesc('created_at'),
           'oldest' => $query->orderBy('created_at'),
           default => $query, // user types ?sort=foobar => error
       };
    }

    // Pagination logic
    public function paginateQuery(Builder $query, $perPage = 10): LengthAwarePaginator
    {
        return $query->paginate($perPage);
    }
}

And implemented this trait in RecipeList.php:

class RecipeList extends Component
{
    use WithPagination;
    use HasSortingAndPagination;

    // public properties are the same

    // protected $queryString also the same

    public function mount(RecipeFilterRequest $request): void
    {
        // mount
    }

    public function render()
    {
        $recipes = $this->getFilteredRecipes();

        return view('livewire.recipe-list', [
            'recipes' => $recipes
        ]);
    }

    public function getFilteredRecipes(): \Illuminate\Pagination\LengthAwarePaginator
    {
        // Filter logic using URL parameters
        $query = Recipe::with('...');

        // Sort filtered recipes using $this->sort
        $this->applySorting($query);

        // Paginate
        return $this->paginateQuery($query, 2);
    }
}

What additional options can I take to avoid repeating myself?

Would be grateful for your help


Solution

  • Using a trait is one way to use reuse functionality. Here are some more ideas:

    Abstract Class

    When two classes have most logic in common, maybe this can be defined in an abstract class. This defines alle the common logic and defines what else is needed to implement it.

    abstract class RecipesList {
        public function render() {
            $receipeQuery = $this->getRecipes();
            $receipeQuery->paginate()
            // ...
        }
    
         abstract protected function getRecipes(): Query;
    }
    

    Now you only need to the getRecipe method in the concrete classes:

    class LikedRecipesList extends RecipeList {
        protected function getRecipes() {
            return Recipe::liked();
        }
    }
    

    Same for your other cases.

    Extension

    Since you can define a 'main' class and two special cases. Instead of writing an abstract class you could define a main class RecipeList and extend the special cases.

    class RecipesList {
        public function render() {
            $receipeQuery = $this->getRecipes();
            $receipeQuery->paginate()
            // ...
        }
    
         protected function getRecipes(): Query {
            return Recipe::query();
        }
    }
    
    class LikedRecipesList extends RecipeList {
        protected function getRecipes() {
            return Recipe::liked();
        }
    }
    

    Property

    Or you could do it all in one class and define a property for the filter.

    class RecipesList {
        public ?string $filter = null;
    
        public function render() {
            $receipeQuery = RecipeQuery
    
            $receipeQuery = match($this->filter) {
                'liked' => $recipeQuery->liked(),
                //other cases
                default => $receipe,
            };
    
            $receipeQuery->paginate()
            // ...
        }
    }
    

    Now you can call the list like <livewire:recipes-list /> for the main list or <livewire:recipes-list filter="liked" /> for the special case

    This one would be my personal favorite.