IncludeBeer

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

Published on 18 April 2021
Article's image
Image credit: Bram Naus

In the fifth part we added a search form. Now we will add a form that will allow us to create, modify and delete recipes (insert, update and delete). We will also see how to do the validation of the data received from a form.

The new routes

First, we need to plan the routes for our new features.

app/Config/Routes.php

$routes->get('/create', 'RecipesController::create');
$routes->get('/edit/(:num)', 'RecipesController::edit/$1');
$routes->get('/delete/(:num)', 'RecipesController::delete/$1');
$routes->post('/save', 'RecipesController::save');
$routes->post('/save/(:num)', 'RecipesController::save/$1');

The /create route will display an empty form, which will allow us to create a new recipe. The /edit/(:num) route will display a form with the information of a recipe to allow us to modify it. The /delete/(:num) route will delete a recipe. Finally the /save route will save a new recipe and the /save/(:num) route will save an existing recipe. The (:num) parameter corresponds to the id of a recipe and means that only a numeric value is accepted. The two routes that do the saving are defined for the post method because it is with this method that the edit form is submitted:

<form action="http://ci4.test:8888/save" method="post" accept-charset="utf-8">

The second parameter to the get() and post() functions is the name of the controller class and the name of the function that will be called. For example, for the route :

$routes->get('/edit/(:num)', 'RecipesController::edit/$1');

...we call the function edit(int $id) of the class RecipesController. The first segment of the route, (:num) in our example, is passed as a parameter to the controller function with the syntax $1.

New configuration file

To simplify the editing form, we will define a maximum number of ingredients that can be added to a recipe. Obviously, we are not going to hard-code this value in the code because we are professionals, so we are going to do it according to the best practices! We could be a little lazy and just create a constant in the app/Config/Constants.php file. But the best practice is to create a specific configuration file for our application, which will be loaded only when needed. A configuration file is simply a class that extends the BaseConfig class, whose public properties are all configuration items. Create the file app/Config/Recipe.php with the property $nb_ingredient.

app/Config/Recipe.php

<?php namespace Config;

use CodeIgniter\Config\BaseConfig;

class Recipe extends BaseConfig
{
    // Number of ingredients per recipe
    public $nb_ingredient = 6;
}

The controller: RecipesController

In the controller, we'll add the four new functions we added to the route configuration, namely create(), edit (int $id), delete (int $id) and save (int $id = null). These functions will load our new configuration file with this command:

$config = config('Recipe');

Then simply specify the desired element: $config->nb_ingredient.

Add the following functions to the RecipesController :

app/Controllers/RecipesController.php

public function create()
{
    // Load the form helpers
    helper('form');

    // Load the configuration for our application
    $config = config('Recipe');

    $data = [
        'page_title' => "New recipe",
        'max_ingredient' => $config->nb_ingredient,
    ];

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

public function edit (int $id)
{
    // Create an instance of our library
    $myRecipes = new MyRecipes();

    // Load the form helpers
    helper('form');

    // Load the configuration for our application
    $config = config('Recipe');

    $data = [
        'page_title' => "Edit a recipe",
        'max_ingredient' => $config->nb_ingredient,
    ];

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

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

public function delete (int $id)
{
    // TODO  
    log_message('debug', "Delete recipe id $id");
}

public function save (int $id = null)
{
    // TODO
    log_message('debug', ($id === null) ? "Save new recipe" : "Save recipe id $id");
}

The recipe_list view : New Create button

On the recipe list page, we add a button to create a new recipe. This button redirects us to the /create route which will display an empty form:

<?= anchor('/create',
           'New recipe',
           ['class' => 'btn btn-outline-success my-1 mr-3']) ?>

Here is the complete view with the new button to create a recipe:

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 $search                            Search criteria
 * @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">

<?php if (session('errors') !== null) : ?>
        <div class="alert alert-danger">
            <?= implode('<br>', session('errors')) ?>
        </div>
<?php endif; ?>

<?php if (session('message') !== null) : ?>
        <div class="alert alert-success text-center">
            <?= session('message'); ?>
        </div>
<?php endif; ?>

        <h3>List of recipe</h3>
        <div class="my-3">
            <?= form_open('/', ['class' => 'form-inline']) ?>
                <?= form_input('search_text',
                               $search['text'] ?? '',
                               ['class' => 'form-control my-1 mr-3', 'placeholder' => "Text"]) ?>

                <?= form_label("Number per page", 'search_nb_per_page', ['class' => 'my-1 mr-2']) ?>

                <?= form_input('search_nb_per_page',
                               $search['nb_per_page'] ?? '',
                               ['id' => 'search_nb_per_page', 'class' => 'form-control my-1 mr-3', 'style' => 'width:70px']) ?>

                <?= form_submit('search_submit',
                                "Search",
                                ['class' => 'btn btn-outline-primary my-1 mr-3']) ?>

                <?= anchor('/create',
                           'New recipe',
                           ['class' => 'btn btn-outline-success my-1 mr-3']) ?>

            <?= form_close() ?>
        </div>

        <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; 2021 <?= anchor('/', "My recipe website")?></p>
</footer>

</body>
</html>

Recipe view: New Edit and Delete buttons

On the page of a recipe, we add two buttons to edit or delete this recipe. The button to edit redirects us to the route /edit which will display a form with the data of the recipe to edit:

<?= anchor("/edit/{$recipe->id}",
           'Edit',
           ['class' => 'btn btn-outline-primary']) ?>

The button to delete asks us for a confirmation with a JavaScript alert. If we confirm, we are redirected to the route /delete which will delete the recipe and then redirect us to the list:

<?= anchor("/delete/{$recipe->id}",
           'Delete',
           [
             'class' => 'btn btn-outline-danger',
             'onClick' => "return confirm('Do you really want to delete this recipe?');"
           ]) ?>

Here is the complete view with the new buttons to edit or delete a recipe:

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><?= nl2br( esc($recipe->instructions) ) ?></p>
        </article>

        <div>
            <?= anchor("/edit/{$recipe->id}",
                       'Edit',
                       ['class' => 'btn btn-outline-primary']) ?>

            <?= anchor("/delete/{$recipe->id}",
                       'Delete',
                       [
                         'class' => 'btn btn-outline-danger',
                         'onClick' => "return confirm('Do you really want to delete this recipe?');"
                       ]) ?>
        </div>

    </div>

</main>

<footer>
    <p class="text-center">&copy; 2021 <?= anchor('/', "My recipe website")?></p>
</footer>

</body>
</html>

New form_recipe view: Edit form

The edit form submits the data to the /save route. We add the id of the recipe if it is an existing recipe:

form_open('/save' . (isset($recipe) ? "/{$recipe->id}" : ""))

The old() function returns the old value of a field if the validation fails and we return to the form: old('title', $recipe->title ?? '', false). We use the operator ?? to determine if the field exists or not. This avoids having to call the isset() function. The false parameter indicates not to call the esc() function which escapes HTML characters. We don't want to call it, because it is already called by the form_input() function and we don't want to end up with characters escaped twice.

You will notice the use of a $max_ingredient variable. It will be defined in the controller and its value will be obtained from the configuration file.

Create a new view for the edit form:

app/Views/form_recipe.php

<?php
/**
 * @var string $page_title                   The page title (automatically created by CI from the $data array)
 * @var int $max_ingredient                  Maximum number of ingredients for a recipe
 * @var App\Entities\Recipe $recipe          The recipe
 */
?>
<!DOCTYPE html>
<html lang="en">
<head>
<title><?= esc($recipe->title ?? "New recipe") ?></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: 1.5rem;
}
</style>

</head>

<body>

<main role="main" class="container">

    <div class="title">
        <h1>
            <?= (isset($recipe) ? "Edit a recipe" : "New recipe") ?>
        </h1>
    </div>

<?php if (session('errors') !== null) : ?>
    <div class="alert alert-danger">
        <?= implode('<br>', session('errors')) ?>
    </div>
<?php endif; ?>

<?php if (session('message') !== null) : ?>
    <div class="alert alert-success text-center">
        <?= session('message'); ?>
    </div>
<?php endif; ?>

    <div class="container">

        <div class="mb-3">
            <?= form_open('/save' . (isset($recipe) ? "/{$recipe->id}" : "")) ?>

                <?= form_label("Title",
                               'form_title',
                               ['class' => 'form-label']) ?>

                <?= form_input('title',
                               old('title', $recipe->title ?? '', false),
                               ['class' => 'form-control mb-3']) ?>

                <?= form_label("Ingredients", '', ['class' => 'form-label']) ?>

                <table class="table table-borderless">
                    <thead>
                        <tr>
                            <th scope="col">#</th>
                            <th scope="col">Quantity</th>
                            <th scope="col">Ingredient</th>
                      </tr>
                    </thead>
                    <tbody>
<?php for ($i = 0; $i < $max_ingredient; $i++): ?>
                        <tr>
                            <th scope="row"><?= ($i + 1) ?></th>
                            <td>
                                <?= form_input("ingredient_quantity_{$i}",
                                               old("ingredient_quantity_{$i}", $recipe->ingredients[$i]->quantity ?? '', false),
                                               ['class' => 'form-control']) ?>
                            </td>
                            <td>
                                <?= form_input("ingredient_name_{$i}",
                                               old("ingredient_name_{$i}", $recipe->ingredients[$i]->name ?? '', false),
                                               ['class' => 'form-control']) ?>
                            </td>
                        </tr>
<?php endfor; ?>
                  </tbody>
                </table>

                <?= form_label("Instructions",
                               'form_instruction',
                               ['class' => 'form-label']) ?>

                <?= form_textarea('instructions',
                                  old('instructions', $recipe->instructions ?? '', false),
                                  ['id' => 'form_instruction', 'class' => 'form-control mb-3']) ?>

                <?= form_submit('form_submit',
                                "Save",
                                ['class' => 'btn btn-outline-primary my-1']) ?>
            <?= form_close() ?>
        </div>

    </div>

</main>

<footer>
    <p class="text-center">&copy; 2021 <?= anchor('/', "My recipe website")?></p>
</footer>

</body>
</html>

The RecipesController

At this point it is possible to display the edit form when you click on Edit. But nothing happens when we click Save or Delete. So we'll add the delete() and save() functions to our controller. These functions will make use of the new functions deleteRecipe() and saveRecipe() that will be added to our library. These functions return true or false on success or error. If the operation ends successfully, we redirect to the recipe list with a message confirming what has just been done: return redirect()->to('/')->with('message', "..."). If the operation failed, we redirect to the recipe list with an error message: return redirect()->to('/')->with('errors', $myRecipes->getErrors()). This command sets a message or errors variable in the user's session with the message text. It is then displayed in the view with <?= session('message') ?> or <?= implode('<br>', session('errors')) ?>.

  if ($myRecipes->deleteRecipe($id))
  {
      return redirect()->to('/')->with('message', "The recipe was successfully deleted.");
  }
  else
  {
      return redirect()->to('/')->with('errors', $myRecipes->getErrors());
  }

Validation

In the case of saving, the validation is more complex. We have to validate the value of each field in the form. The controller makes our life easier thanks to its validate() function. We just have to pass it the rules for each of the fields of our form and it takes care of getting the values and passing them to the Validation class. In case of error, we return to the form page: redirect()->back(), passing the form data to it: withInput(), so that the user can make the required corrections. We also get the error messages generated by the validation class which will tell us the name of the problematic fields and the reason for the failure: $this->validator->getErrors().

if ( ! $this->validate($rules))
{
    return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}

Replace the following functions in the RecipesController :

app/Controllers/RecipesController.php

    public function delete (int $id)
    {
        // Create an instance of our library
        $myRecipes = new MyRecipes();

        if ($myRecipes->deleteRecipe($id))
        {
            return redirect()->to('/')->with('message', "The recipe was successfully deleted.");
        }
        else
        {
            return redirect()->to('/')->with('errors', $myRecipes->getErrors());
        }
    }

    public function save (int $id = null)
    {
        log_message('debug', ($id === null) ? "Save new recipe" : "Save recipe id $id");

        // Load the configuration for our application
        $config = config('Recipe');

        /*
         * Define the validation rules for our form
         */
        $rules = [
            'title' => [
                'label' => "Title",
                'rules' => "required|max_length[100]|is_unique[recipe.title,id,{$id}]"
            ],
            'instructions' => [
                'label' => "Instructions",
                'rules' => "required|string"
            ],
        ];

        for ($i = 0; $i < $config->nb_ingredient; $i++)
        {
            $ingredient_no = $i + 1;

            $rules["ingredient_quantity_{$i}"] = [
                'label' => "Quantity for ingredient {$ingredient_no}",
                'rules' => "permit_empty|string|max_length[10]|required_with[ingredient_name_{$i}]"
                ];

            $rules["ingredient_name_{$i}"] = [
                'label' => "Name of ingredient {$ingredient_no}",
                'rules' => "permit_empty|string|max_length[50]|required_with[ingredient_quantity_{$i}]"
                ];
        }

        /*
         * Validate the form data
         */
        if ( ! $this->validate($rules))
        {
            return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
        }

        // Create an instance of our library
        $myRecipes = new MyRecipes();

        // Get form data
        $form_data_recipe = [
            'title' => $this->request->getPost('title'),
            'instructions' => $this->request->getPost('instructions'),
        ];

        // Extract and validate the ingredients of this recipe
        $form_data_ingredients = [];

        for ($i = 0; $i < $config->nb_ingredient; $i++)
        {
            if ( ! empty($this->request->getPost("ingredient_quantity_{$i}")) &&
                 ! empty($this->request->getPost("ingredient_name_{$i}")))
            {
                $form_data_ingredients[] = [
                    'quantity' => $this->request->getPost("ingredient_quantity_{$i}"),
                    'name' => $this->request->getPost("ingredient_name_{$i}"),
                ];
            }
        }

        // Get the form data and save it
        if ($myRecipes->saveRecipe($id, $form_data_recipe, $form_data_ingredients))
        {
            return redirect()->to('/')->with('message', "Recipe saved successfully");
        }
        else
        {
            return redirect()->back()->withInput()->with('errors', $myRecipes->getErrors());
        }
    }

MyRecipes library

Now we just need to add the new functions to delete and save a recipe in our MyRecipes library. These new functions are called by the controller.

The function deleteRecipe(int $id) deletes all the ingredients whose recipe_id corresponds to the recipe to be deleted, then we delete the recipe itself. To do this, we call the function $this->recipeModel->delete($id) which takes care of doing the DELETE in the database. The function $this->recipeModel->db->affectedRows() returns the number of deleted rows, which allows us to know if the deletion has been done.

Then, the function saveRecipe(?int $id, array $form_data_recipe, array $form_data_ingredients) allows us to save a recipe and its ingredients in the database. This is the same function that is used to create a new recipe or to update an existing one. The first parameter is the id of the recipe. Its type is ?int, which means that we accept a numeric value or null. In the case of a new recipe, it doesn't have an id yet, so we pass null. If we have an id, we get the recipe with the function $this->recipeModel->find($id) and we overwrite the values of the $recipe object by those entered in the form with the function $recipe->fill($form_data_recipe). To avoid an unnecessary update, the function $recipe->hasChanged() allows us to know if something has changed. Finally, the function $this->recipeModel->save($recipe) takes care of doing either an INSERT or an UPDATE depending on the case. If we create a new recipe, $this->recipeModel->db->insertID() returns the id of the recipe just created.

Here is the code of the new functions added to the MyRecipes library. The content of some functions has been removed to avoid the code being unnecessarily long. Please refer to the fifth part for the code of these functions.

app/Libraries/MyRecipes.php

<?php namespace App\Libraries;

use App\Models\RecipeModel;
use App\Models\IngredientModel;
use App\Entities\Recipe;
use App\Entities\Ingredient;

class MyRecipes
{
    public $recipeModel;
    public $ingredientModel;
    private $errors;

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

    /**
     * Define the error messages
     * @param array|string $errors
     */
    private function setErrors ($errors)
    {
        // If we receive something other than an array, convert to array
        $this->errors = is_array($errors) ? $errors : (array)$errors;
    }

    /**
     * Return the error messages
     * @return array
     */
    public function getErrors(): array
    {
        return $this->errors;
    }

    public function getListRecipes (array $search)
    {
        // See part 5 of this series of articles
    }

    public function getRecipeById (int $id)
    {
        // See part 5 of this series of articles
    }

    public function getRecipeBySlug (string $slug)
    {
        // See part 5 of this series of articles
    }

    /**
     * Delete a recipe and its ingredients
     * @param int $id
     * @return bool
     */
    public function deleteRecipe (int $id): bool
    {
        // First delete the ingredients from this recipe
        if ( ! $this->ingredientModel
                ->where( ['recipe_id' => $id] )
                ->delete() )
        {
            $this->setErrors($this->ingredientModel->errors());
            return false;
        }

        // Delete the recipe
        if ( ! $this->recipeModel->delete($id))
        {
            $this->setErrors($this->recipeModel->errors());
            return false;
        }

        // Get the number of deleted rows
        $nb_delete = $this->recipeModel->db->affectedRows();
        log_message('debug', "$nb_delete recipe deleted");

        // If no rows have been deleted
        if ($nb_delete === 0)
        {
            $this->setErrors("No recipe found with id $id");
            return false;
        }

        return true;
    }

    /**
     * Save a recipe and its ingredients
     * @param int $id
     * @param array $form_data_recipe
     * @param array $form_data_ingredients
     * @return bool
     */
    public function saveRecipe (?int $id, array $form_data_recipe, array $form_data_ingredients): bool
    {
        // If we have an id, get the recipe to update
        if ( ! is_null($id))
        {
            if ( ! $recipe = $this->recipeModel->find($id) )
            {
                $this->setErrors("No recipe found");
                return false;
            }
        }
        else
        {
            $recipe = new Recipe();
        }

        // Fill in the fields of the Recipe object with the form data
        $recipe->fill($form_data_recipe);
        log_message('debug', "Recipe: " . print_r($recipe, true));

        if ($recipe->hasChanged())
        {
            // The save() function takes care of doing an INSERT or an UPDATE, depending on the case.
            if ( ! $this->recipeModel->save($recipe))
            {
                $this->setErrors($this->recipeModel->errors());
                return false;
            }
        }

        // If it is a new recipe, get its ID
        if (is_null($id))
        {
            $id = $this->recipeModel->db->insertID();
            log_message('debug', "New recipe id $id");
        }
        // If this is not a new recipe, delete its ingredients
        else
        {
            if ( ! $this->ingredientModel
                ->where( ['recipe_id' => $id] )
                ->delete() )
            {
                $this->setErrors($this->ingredientModel->errors());
                return false;
            }
        }

        // Add the new ingredients for this recipe
        foreach ($form_data_ingredients as $data_ingredient)
        {
            // Link the ingredient to the recipe
            $data_ingredient['recipe_id'] = $id;

            log_message('debug', "Data Ingredient: " . print_r($data_ingredient, true));

            // Create an Ingredient entity with the form data
            $ingredient = new Ingredient();
            $ingredient->fill($data_ingredient);

            log_message('debug', "Ingredient: " . print_r($ingredient, true));

            // Insert this ingredient in the database
            if ( ! $this->ingredientModel->save($ingredient) )
            {
                $this->setErrors($this->ingredientModel->errors());
                return false;
            }
        }

        return true;
    }
}

You may have noticed two new functions in our library: setErrors($errors) and getErrors(). They allow respectively to keep in memory or to return the errors occurred during the processing. We could have handled errors with Exception and try/catch, but in this case I think this approach is simpler. In case of an error, the controller can get the error messages and pass them to the view to present them to the user.

To help you understand who does what in the CodeIgniter framework, here is where the functions used in our library come from. The functions find() and save() are defined in the CodeIgniter\BaseModel class which happens to be the base class of the CodeIgniter\Model class. The functions fill() and hasChanged() are defined in the class CodeIgniter\Entity which is the base class of the objects we manipulate with the database. Generally an entity represents a row in a MySQL table. The functions affectedRows() and insertID() are defined in the abstract class CodeIgniter\Database\BaseConnection and are implemented in the class CodeIgniter\Database\MySQLi\Connection.

Model and entity

In order for CodeIgniter to allow us to add ingredients, we need to add the recipe_id field to the list of updatable fields. Add the recipe_id field to the IngredientModel :

app/Models/IngredientModel.php

protected $allowedFields = [
    'name',
    'quantity',
    'recipe_id',
];

An interesting feature of the Entity class, is that if there is a function with the same name as one of its properties and is prefixed with the word set, it is called automatically when we assign a value to that property. For example, for the property title we can create a function setTitle() in the entity Recipe and when we assign a value to the field title, the function will be called automatically. This is where we will generate the slug of the recipe with the mb_url_title() function.

Add the setTitle() function to the Recipe entity:

app/Entities/Recipe.php

// This function is automatically called when you set the value of $recipe->title
public function setTitle (string $title)
{
    $this->attributes['title'] = $title;

    // Automatically define the "slug" from the recipe title.
    $this->attributes['slug'] = mb_url_title($title, '-', TRUE);
}

That was the last part of this series!

You can now refresh the http://ci4.test:8888 page which will now display a button to create a new recipe. On the recipe page, there are two new buttons to edit or delete the recipe. This article concludes this series of articles. There are obviously many other improvements that could be added, but this covers the basic functionality of a web application created with CodeIgniter 4.

Beer If you enjoy my tutorials, you can buy me the equivalent of a coffee, a beer or a lunch!

Paypal