IncludeBeer

How to build a basic web application with CodeIgniter 4 (part 4)

Published on 19 December 2020 Last update on 28 December 2020
Article's image
Image credit: Bram Naus

In the third part we have improved the presentation by displaying each recipe on a separate page. Now we are going to improve it a bit more by adding the pagination of the recipe list.

First of all, add the slug field to the list of updatable fields in the RecipeModel class:

app/Models/RecipeModel.php

<?php namespace App\Models;

use CodeIgniter\Model;
use App\Entities\Recipe;

class RecipeModel extends Model
{
    protected $table = 'recipe';
    protected $returnType = Recipe::Class;

    protected $allowedFields = [
        'title',
        'instructions',
        'slug',
    ];
}

You can also delete the List of recipe by id in the recipe_list view to keep only the List of recipe by slug. Delete the following lines:

    <h3>List of recipe by id</h3>
    <ul>
<?php foreach ($recipes as $recipe): ?>
          <li><?= anchor('recipe/' . $recipe->id, $recipe->title) ?></li>
<?php endforeach; ?>
    </ul>

Insert dummy data with the Seeder class

To test the pagination, it will take us a lot more data than what we inserted in the previous parts of this series of articles. To make our life easier, we are going to use the Seeder class. This class allows us to create data from the command line (CLI). The RecipesSeeder class is very simple, we get an instance of the Query Builder for each of the recipe and ingredient tables, we insert 500 new recipes with the title "Dummy recipe number X", and for each recipe we insert three ingredients A, B and C in a single step with the function insertBatch().

Create the RecipesSeeder class int the app/Database/Seeds directory:

app/Database/Seeds/RecipesSeeder.php

<?php namespace App\Database\Seeds;

use CodeIgniter\Database\Seeder;

class RecipesSeeder extends Seeder
{
    public function run()
    {
        // Get an instance of the Query Builder for the RECIPE table
        $qb_recipe = $this->db->table('recipe');

        // Get an instance of the Query Builder for th INGREDIENT table
        $qb_ingredient = $this->db->table('ingredient');

        // Number of dummy recipe to create
        $nb_recipe = 500;

        // Loop 500 times
        for ($recipe_no = 1; $recipe_no <= $nb_recipe; $recipe_no++)
        {
            // Define a dummy recipe
            $recipe = [
                'title'  => "Dummy recipe {$recipe_no}",
                'slug' => "dummy-recipe-no-{$recipe_no}",
                'instructions' => <<<EOT
Add all the ingredients to a baking dish.
Bake at 350 °F (180 °C) for 45 minutes.
EOT
            ];

            // Insert this recipe
            $qb_recipe->insert($recipe);

            // Get the ID of the inserted recipe
            $recipe_id = $this->db->insertID();
            log_message('debug', "Inserted ID: $recipe_id");

            // Define 3 dummy ingredients associated to this recipe
            $ingredients = [
                [
                    'name' => "Ingredient A",
                    'quantity'  => "200 g",
                    'recipe_id'  => $recipe_id
                ],
                [
                    'name' => "Ingredient B",
                    'quantity'  => "50 g",
                    'recipe_id'  => $recipe_id
                ],
                [
                    'name' => "Ingredient C",
                    'quantity'  => "25 g",
                    'recipe_id'  => $recipe_id
                ]
            ];

            // Insert these 3 ingredients
            $qb_ingredient->insertBatch($ingredients);
        }
    }
}

Run a Seeder class

The command to run a "seeder" is:

php spark db:seed RecipesSeeder

This command must be executed at the location of the spark script. Normally it is in the root directory of the CodeIgniter installation. That is, one level above the app and system directories. In my case, I am using MAMP on macOS and my application is installed in the ci4.test directory. Also, I am not using the PHP installed globally on my computer. I am using version 7.4.2 which is installed in MAMP. So just open a Terminal window and type the following commands:

cd /Applications/MAMP/htdocs/ci4.test
/Applications/MAMP/bin/php/php7.4.2/bin/php spark db:seed RecipesSeeder

The execution is very fast, 2000 rows are inserted in less than three seconds (500 recipes + 1500 ingredients). You can now refresh the page http://ci4.test:8888 which will now display a list of 500 recipes, or more if you have kept the recipes inserted in the second part.

Pagination

Ok, a list of 500 items doesn't make sense. It's time to implement pagination!

The MyRecipes library

The easiest way to paginate the results is to use the paginate() function of the Model class instead of the findAll() function. This function does a lot of the work for us. It will automatically read the page number variable passed in the URL (?page=1), will configure the Pager class and will add the LIMIT clause to the SQL query, and then return the final result obtained by findAll(). To use pagination, the controller needs to access the model because we must use the instance of the Pager that it used to prepare the pagination. We are therefore going to modify our MyRecipes library to make the instances of the models accessible via public variables.

app/Libraries/MyRecipes.php

<?php namespace App\Libraries;

use App\Models\RecipeModel;
use App\Models\IngredientModel;

class MyRecipes
{
    public $recipeModel;
    public $ingredientModel;

    public function __construct()
    {
        $this->recipeModel = new RecipeModel();
        $this->ingredientModel = new IngredientModel();
    }

    /**
     * Get the list of recipes
     * @return array
     */
    public function getListRecipes ()
    {
        // Only get id, slug and title fields
        $this->recipeModel->select('id, slug, title');

        $recipes = $this->recipeModel
            ->orderBy('id')
            ->paginate();

        return $recipes;
    }

    /**
     * Get a recipe by its id
     * @param int $id
     * @return object|NULL
     */
    public function getRecipeById (int $id)
    {
        // Get the recipe by its id
        $recipe = $this->recipeModel->find($id);

        if ($recipe !== null)
        {
            $recipe->ingredients = $this->ingredientModel
                ->where( ['recipe_id' => $recipe->id] )
                ->orderBy('id')
                ->findAll();
        }

        return $recipe;
    }

    /**
     * Get a recipe by its slug
     * @param string $slug
     * @return object|NULL
     */
    public function getRecipeBySlug (string $slug)
    {
        // Get the recipe by its slug
        $recipe = $this->recipeModel->where('slug', $slug)->first();

        if ($recipe !== null)
        {
            $recipe->ingredients = $this->ingredientModel
                ->where( ['recipe_id' => $recipe->id] )
                ->orderBy('id')
                ->findAll();
        }

        return $recipe;
    }
}

The only thing to change in the controller is to get the instance of the Pager class used by the model and pass it to the view. This will allow us to display links to other pages.

app/Controllers/RecipesController.php

<?php namespace App\Controllers;

use App\Libraries\MyRecipes;

class RecipesController extends BaseController
{
    /**
     * List of recipes
     * @return string
     */
    public function index()
    {
        // Create an instance of our library
        $myRecipes = new MyRecipes();

        // Collect all the data used by the view in a $data array
        $data = [
            'page_title' => "My Recipes",
            'page_subtitle' => "I present you my favorite recipes...",
            'recipes' => $myRecipes->getListRecipes(),
            // Pass the paginnation class instance to the view
            'pager' => $myRecipes->recipeModel->pager,
        ];

        /* Each of the items in the $data array will be accessible
         * in the view by variables with the same name as the key:
         * $page_title, $page_subtitle, $recipes and $pager
         */
        return view('recipe_list', $data);
    }

    /**
     * One recipe
     * @param int $id
     * @return string
     */
    public function recipeById (int $id)
    {
        // Create an instance of our library
        $myRecipes = new MyRecipes();

        $data = [];

        /* Get the recipe for the id received in parameter.
         * If the recipe does not exist, throw a page not found exception (error 404)
         */
        if ( ! $data['recipe'] = $myRecipes->getRecipeById($id))
        {
            throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
        }

        return view('recipe', $data);
    }

    /**
     * One recipe
     * @param string $slug
     * @return string
     */
    public function recipeBySlug (string $slug)
    {
        // Create an instance of our library
        $myRecipes = new MyRecipes();

        $data = [];

        /* Get the recipe for the slug received in parameter.
         * If the recipe does not exist, throw a page not found exception (error 404)
         */
        if ( ! $data['recipe'] = $myRecipes->getRecipeBySlug($slug))
        {
            throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
        }

        return view('recipe', $data);
    }
}

Configuration

The Pager class is a CodeIgniter system class which allows us to do pagination and among other things to generate links to other pages. It needs a configuration file to define the available templates and the default number of items to display per page. Here we add a new template that we will use instead of the default CodeIgniter templates. The default templates are very basic and instead of writing CSS we will be using Bootstrap CSS classes.

app/Config/Pager.php

<?php namespace Config;

use CodeIgniter\Config\BaseConfig;

class Pager extends BaseConfig
{
    // Pagination template aliases
    public $templates = [
        // Default templates provided with CodeIgniter
        'default_full'   => 'CodeIgniter\Pager\Views\default_full',
        'default_simple' => 'CodeIgniter\Pager\Views\default_simple',
        // Custom template to use Bootstrap
        'bootstrap'      => 'App\Views\pagination_bootstrap',
    ];

    // Default value for the number of item per page
    public $perPage = 25;
}

Bootstrap template for the pagination

Our custom template is not much different from the original template. We add the CSS classes page-item and page-link, and we add links to the previous page and the next page with the help of the getPreviousPage() and getNextPage() functions.

app/Views/pagination_bootstrap.php

<?php
/**
 * @var \CodeIgniter\Pager\PagerRenderer $pager  Pagination class instance
 * @var array $link                              Information for a link to a page (created by the foreach instruction)
 */

// Set how many page nunber to show on each sides of the current page number
$pager->setSurroundCount(2);
?>

<nav aria-label="<?= lang('Pager.pageNavigation') ?>">
    <ul class="pagination justify-content-center">
    <?php if ($pager->hasPrevious()) : ?>
        <li class="page-item">
            <a href="<?= $pager->getFirst() ?>" aria-label="<?= lang('Pager.first') ?>" class="page-link">
                <span aria-hidden="true"><?= lang('Pager.first') ?></span>
            </a>
        </li>
        <li>
            <a href="<?= $pager->getPreviousPage() ?>" aria-label="<?= lang('Pager.previous') ?>" class="page-link">
                <span aria-hidden="true"><?= lang('Pager.previous') ?></span>
            </a>
        </li>
        <li class="page-item">
            <a href="<?= $pager->getPrevious() ?>" class="page-link">
                <span aria-hidden="true">&laquo;</span>
            </a>
        </li>
    <?php endif ?>

    <?php foreach ($pager->links() as $link) : ?>
      <li class="<?= $link['active'] ? 'page-item active' : 'page-item' ?>">
            <a href="<?= $link['uri'] ?>" class="page-link">
                <?= $link['title'] ?>
            </a>
        </li>
    <?php endforeach ?>

    <?php if ($pager->hasNext()) : ?>
        <li class="page-item">
            <a href="<?= $pager->getNext() ?>" class="page-link">
                <span aria-hidden="true">&raquo;</span>
            </a>
        </li>
        <li>
          <a href="<?= $pager->getNextPage() ?>" aria-label="<?= lang('Pager.next') ?>" class="page-link">
              <span aria-hidden="true"><?= lang('Pager.next') ?></span>
            </a>
        </li>
        <li class="page-item">
            <a href="<?= $pager->getLast() ?>" aria-label="<?= lang('Pager.last') ?>" class="page-link">
                <span aria-hidden="true"><?= lang('Pager.last') ?></span>
            </a>
        </li>
    <?php endif ?>
    </ul>
</nav>

The recipe_list view

Now all you have to do is display the links to the other pages with the $pager->links() function. However, this uses the default CodeIgniter template. We are therefore going to call the function by specifying it to use our custom template that we named bootstrap in the configuration file. We could have given it any other name. The first parameter is used to define the pagination group in cases where there are several lists to paginate on the same page. As we only have one set of results to paginate, we pass 'default':

<?= $pager->links('default', 'bootstrap') ?>

app/Views/recipe_list.php

<?php
/**
 * @var string $page_title                       The page title (automatically created by CI from the $data array)
 * @var string $page_subtitle                    The page subtitle (automatically created by CI from the $data array)
 * @var array  $recipes                          List of recipes (automatically created by CI from the $data array)
 * @var App\Entities\Recipe $recipe              One recipe (created by the foreach instruction)
 * @var \CodeIgniter\Pager\PagerRenderer $pager  Pagination class instance
 */
?>
<!DOCTYPE html>
<html lang="en">
<head>
<title><?= esc($page_title) ?></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet"
      href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css"
      integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk"
      crossorigin="anonymous">

<style type="text/css">
.title
{
    padding: 3rem 1.5rem;
}

article
{
    padding: 1.5rem 1.5rem;
}
</style>

</head>

<body>

<main role="main" class="container">
    <div class="title">
        <h1>
          <?= esc($page_title) ?>
          <small class="text-muted"><?= esc($page_subtitle) ?></small>
      </h1>
    </div>

    <div class="container">

    <h3>List of recipe</h3>
    <ul>
<?php foreach ($recipes as $recipe): ?>
          <li><?= anchor('recipe/' . $recipe->slug, $recipe->title) ?></li>
<?php endforeach; ?>
    </ul>

        <?= $pager->links('default', 'bootstrap') ?>

    </div>

</main>

<footer>
    <p class="text-center">&copy; 2020 My recipe website</p>
</footer>

</body>
</html>

The recipe view

In the recipe view, I added a call to the nl2br() function which means "new line to break". That is, it transforms the newline characters \n into their HTML equivalent: <br>. Add the function call at the line where the instructions are displayed:

app/Views/recipe.php

<h5>Instructions</h5>
<p><?= nl2br( esc($recipe->instructions) ) ?></p>

Fourth part finished!

You can now refresh the page http://ci4.test:8888 which will now display 25 recipes per page with links to go to the next and previous page, first and last page, page numbers and links to move forward or backward several pages. Now that we can see what the application looks like with a ton of recipes spread over several pages, it's obvious that it's going to need a search form. This is what we are going to do right now in the fifth part.