IncludeBeer

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

Published on 22 November 2020
Article's image
Image credit: Bram Naus

In the second part we created a very simple web application with a database. Now we are going to improve the presentation by showing each recipe on a separate page.

Database modifications

The easiest way to get a row from the database is to search for it using its primary key, or its id. But an url like http://ci4.test/recipe/1 is not very interesting. If we just see this url, we have no idea what recipe it is. From an SEO perspective, that's not a good idea either. We will therefore add a slug field which will allow us to have urls which include the title of the recipe: http://ci4.test/recipe/boiling-water.

Run the following script to add the new column and populate it with a title that can be used in an url:

--
-- Table for the recipes
--
ALTER TABLE `recipe`
  ADD `slug` varchar(255);

--
-- Update the recipes
--
UPDATE `recipe`
SET `slug` = 'boiling-water'
WHERE `title` = 'Boiling Water';

UPDATE `recipe`
SET `slug` = 'tea'
WHERE `title` = 'Tea';

UPDATE `recipe`
SET `slug` = 'glass-of-water'
WHERE `title` = 'Glass of water';

Routes

To display a recipe on a separate page, add a new route to a recipe page: recipe/(:slug). But to demonstrate both ways, we also add a route to a recipe page using its id: recipe/(:num). For the route to recipe/(:slug), we need to define a regex that accepts alpha-numeric characters separated by hyphens. It is the $routes->addPlaceholder() function that allows us to define the (:slug) type. You could give it any other name, but slug is the term usually used to define the title of an article in a url. Here is the new definition of routes, including the new placeholder 'slug', as well as the routes to the recipe list and the two routes to a recipe, by id and by slug.

app/Config/Routes.php

/**
 * --------------------------------------------------------------------
 * Route Definitions
 * --------------------------------------------------------------------
 */

/**
 * slug placeholder:
 *
 *  [a-z0-9]+     # One or more repetition of given characters
 *  (?:           # A non-capture group.
 *    -           # A hyphen
 *    [a-z0-9]+   # One or more repetition of given characters
 *  )*            # Zero or more repetition of previous group
 *
 *  This will match:
 *  - A sequence of alphanumeric characters at the beginning.
 *  - Then it will match a hyphen, then a sequence of alphanumeric characters, 0 or more times.
 *
 * Examples :
 *   item12345
 *   some-blog-article
 *
 */
$routes->addPlaceholder('slug', '[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*');

// Define routes for /, recipe/id and recipe/slug
$routes->get('/', 'RecipesController::index');
$routes->get('recipe/(:num)', 'RecipesController::recipeById/$1');
$routes->get('recipe/(:slug)', 'RecipesController::recipeBySlug/$1');

The MyRecipes library

In the MyRecipes library, we add a new function getListRecipes() to obtain the list of recipes. This new function is now used instead of getAllRecipes(). It obtains the minimum information required to display the list: the id, the slug and the title. You don't get the directions or the ingredients list. We also add the functions getRecipeById(int $id) and getRecipeBySlug(string $slug) to obtain a single recipe, either by its id or by its slug.

app/Libraries/MyRecipes.php

<?php namespace App\Libraries;

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

class MyRecipes
{
    /**
     * Get all recipes
     * @return array
     */
    public function getAllRecipes ()
    {
        // Create an instance for our two models
        $recipeModel = new RecipeModel();
        $ingredientModel = new IngredientModel();

        // SELECT the recipes, order by id
        $recipes = $recipeModel
            ->orderBy('id')
            ->findAll();

        // For each recipe, SELECT its ingredients
        foreach ($recipes as &$recipe)
        {
            $recipe->ingredients = $ingredientModel
                ->where( ['recipe_id' => $recipe->id] )
                ->orderBy('id')
                ->findAll();
        }
        unset($recipe);

        return $recipes;
    }

    /**
     * Get the list of recipes
     * @return array
     */
    public function getListRecipes ()
    {
        $recipeModel = new RecipeModel();

        // Only get id, slug and title fields
        $recipes = $recipeModel
            ->select('id, slug, title')
            ->orderBy('id')
            ->findAll();

        return $recipes;
    }

    /**
     * Get a recipe by its id
     * @param int $id
     * @return object|NULL
     */
    public function getRecipeById (int $id)
    {
        $recipeModel = new RecipeModel();
        $ingredientModel = new IngredientModel();

        // Get the recipe by its id
        $recipe = $recipeModel->find($id);

        if ($recipe !== null)
        {
            $recipe->ingredients = $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)
    {
        $recipeModel = new RecipeModel();
        $ingredientModel = new IngredientModel();

        // Get the recipe by its slug
        $recipe = $recipeModel->where('slug', $slug)->first();

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

        return $recipe;
    }
}

The controller: RecipesController

In the controller, the recipe list is now obtained with the new function $myRecipes->getListRecipes(). We also add two new functions to display a recipe on a separate page by searching for it by its id or by its slug: recipeById(int $id) and recipeBySlug(string $slug). These functions display the new recipe view.

app/Controllers/RecettesController.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(),
        ];

        /* 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 and $recipes
         */
        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);
    }
}

The recipe_list view

In the recipe_list view, we made some adjustments to display the list of recipes rather than the complete recipes. We use the anchor() function to generate a hyperlink to the recipe page. This function is part of the URL Helper functions and is loaded automatically by the framework. To demonstrate the two ways of doing this, we generate a list of hyperlinks that get the recipes by their id, then a list that gets them by their slug.

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)
 */
?>
<!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 by id</h3>
    <ul>
<?php foreach ($recipes as $recipe): ?>
          <li><?= anchor('recipe/' . $recipe->id, $recipe->title) ?></li>
<?php endforeach; ?>
    </ul>

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

    </div>

</main>

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

</body>
</html>

The recipe view

The new recipe view displays the full recipe, including instructions and ingredients.

app/Views/recipe.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 App\Entities\Ingredient $ingredient  One ingredient (created by the foreach instruction)
 */
?>
<!DOCTYPE html>
<html lang="en">
<head>
<title><?= esc($recipe->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($recipe->title) ?>
      </h1>
    </div>

    <div class="container">
        <article>
            <h5>Ingredients</h5>
            <ul>
            <?php foreach ($recipe->ingredients as $ingredient): ?>
                <li><?= esc($ingredient->quantity) ?> <?= esc($ingredient->name) ?></li>
            <?php endforeach; ?>
            </ul>
            <h5>Instructions</h5>
            <p><?= esc($recipe->instructions) ?></p>
        </article>
    </div>

</main>

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

</body>
</html>

Third part finished!

You can now refresh the page http://ci4.test:8888 which will now display the list of recipe titles. Clicking on a recipe displays a new page with all the recipe information. You can access a recipe by its id or by its slug. Now the page is a bit cleaner with just the recipe titles. But if we have 500 of them, it would be much better if we could display the list on several pages. This is what we are going to do right now in the fourth part.