Managing the codebase is a major bottleneck for software development teams. Letting teams work at their own pace has to be balanced with making sure the codebase is functional at all times. Many teams use branches in the code repository to try to maintain this balance. Some teams create a branch for each new feature, while others use different branches for each environment (development, staging, production, for example). Regardless of the methodology you use, you will need to merge branches at some point, usually when the changes have been approved. The bottleneck worsens as the complexity of the application increases or the size of the team grows. Issues your team encounters while merging into the production branch could delay deployment of a new feature or cause unexpected downtime, which can adversely affect customer morale, and negatively impact sales.

Continuous integration (CI) aims to solve these problems. Using CI, you can trigger integration by simply pushing the relevant code for the new feature to the main branch of the repository. CI pipelines let you maintain a main branch that everyone can push to. The newly added code is allowed into the main branch only if it builds successfully. Not only does this save time, but it also helps reduce the complexities that are introduced by human error. CI makes sure that software updates can be executed in a fast and reliable manner.

In this tutorial, I will show you how to use CircleCI for the continuous integration of a CodeIgniter API.

Prerequisites

A basic understanding of CodeIgniter may be helpful, but I will provide explanations and links to official documentation throughout the tutorial. If you are unclear on any concept, you can review the linked material before continuing.

Before you start, make sure these items are installed on your system:

  • Composer: Composer will be used for dependency management in your CodeIgniter project.
  • A local database instance. While MySQL will be used in this tutorial, you are free to select your preferred database service.

For repository management and continuous integration, you need:

  • A GitHub account. You can create one here.
  • A CircleCI account. You can create one here. To easily connect your GitHub projects, you can sign up with your GitHub account.

Getting started

To get started, create a new CodeIgniter project:

$ composer create-project codeigniter4/appstarter ci-test-circleci

This spins up a new CodeIgniter project, with all its dependencies, installed in a folder named ci-test-circleci.

Run the application

Move to the project folder and run the application:

$ php spark serve

Navigate to http://localhost:8080/ from your browser to open the welcome page.

CodeIgniter Homepage

In your terminal, press CTRL + C to stop the application while we continue with the rest of our setup.

For this tutorial, we’ll build an API to manage blog articles. The API will have endpoints to create, read, update, and delete blog posts. To keep things simple, a blog post will have three fields:

  • Title
  • Author
  • Content

Our API will add a primary key (id), a created_at field, and an updated_at field.

To start things off, set up your local env file and database.

Setting up the local environment

Copy the env file into .env file using this command:

$ cp env .env

CodeIgniter starts up in production mode by default. In this tutorial, we will change it to development mode. In the .env file, uncomment the CI_ENVIRONMENT variable and set it to development:

CI_ENVIRONMENT = development

Next, create a database within your local environment. Uncomment the following variables to update each value and set up a successful connection to the database:

database.default.hostname = localhost
database.default.database = YOUR_DATABASE_NAME
database.default.username = YOUR_DATABASE_USERNAME
database.default.password = YOUR_DATABASE_PASSWORD
database.default.DBDriver = MySQLi # this is the driver for a mysql connection. There are also drivers available for postgres & sqlite3.

Replace the YOUR_DATABASE, YOUR_DATABASE_USERNAME and YOUR_DATABASE_PASSWORD placeholders with values specific to your project.

Next, we need to create a migration file and seed our database with some posts.

Migrations and seeders

Now that you have created a database and set up a connection to it, create migrations for the post table.

From the terminal, create a migration file using the CodeIgniter CLI tool:

$ php spark migrate:create

The CLI will ask you to name the migration file. Then, it will create the migration file in the app/Database/Migrations directory. The name of the migration we are creating is add_post.

The migration filename will be prefixed with a numeric sequence in the YYYY-MM-DD-HHIISS format. Go to the CodeIgniter documentation for a more detailed explanation.

Next, open the migration file located in app/Database/Migrations/YYYY-MM-DDHHIISS_add_post.php and update its content:

<?php

namespace app\Database\Migrations;

use CodeIgniter\Database\Migration;

class AddPost extends Migration
{
    public function up()
    {
        $this->forge->addField([
            'id' => [
                'type' => 'INT',
                'constraint' => 5,
                'unsigned' => true,
                'auto_increment' => true,
            ],
            'title' => [
                'type' => 'VARCHAR',
                'constraint' => '100',
                'null' => false
            ],
            'author' => [
                'type' => 'VARCHAR',
                'constraint' => '100',
                'null' => false,
            ],
            'content' => [
                'type' => 'VARCHAR',
                'constraint' => '1000',
                'null' => false,
            ],
            'updated_at' => [
                'type' => 'datetime',
                'null' => true,
            ],
            'created_at datetime default current_timestamp',
        ]);
        $this->forge->addPrimaryKey('id');
        $this->forge->createTable('post');
    }

    public function down()
    {
        $this->forge->dropTable('post');
    }
}

Now, run your migrations:

 $ php spark migrate

This command creates a post table in your database and adds the columns listed in the migration.

To make development easier, seed your database with some dummy client data. The fzaninotto faker bundle is a default dev dependency in the CodeIgniter skeleton. You can use it to add random posts to the database. Just as you did for the migration, use the CodeIgniter CLI tool to create a seeder for posts. Run:

$ php spark make:seeder

When the CLI prompts you for a name, enter PostSeeder. A PostSeeder.php file will be created in the app/Database/Seeds directory. Open the file and replace its content with:

<?php
namespace app\Database\Seeds;

use CodeIgniter\Database\Seeder;
use Faker\Factory;

class PostSeeder extends Seeder
{
    public function run()
    {
        for ($i = 0; $i < 10; $i++) { //to add 10 posts. Change limit as desired
            $this->db->table('post')->insert($this->generatePost());
        }
    }

    private function generatePost(): array
    {
        $faker = Factory::create();
        return [
            'title' => $faker->sentence,
            'author' => $faker->name,
            'content' => $faker->paragraphs(4, true),
        ];
    }
}

Next, seed the database with dummy clients. Run:

$ php spark db:seed PostSeeder

Database View

Entity model

We will use CodeIgniter’s Model for the API’s interaction with the database. First, create a model for the blog table by opening the app/Models directory and creating a file named PostModel.php. Add:

<?php


use CodeIgniter\Model;

class PostModel extends Model
{
    protected $table = 'post';

    protected $allowedFields = [
        'title',
        'author',
        'content',
    ];

    protected $updatedField = 'updated_at';

    public function getAllPosts(): array
    {
        return $this->findAll();
    }

    public function findPost($id): array
    {
        $post = $this
            ->asArray()
            ->where(['id' => $id])
            ->first();

        if (!$post) throw new Exception('Could not find post for specified ID');

        return $post;
    }

    public function savePost(array $postDetails): array
    {
        $postId = (int)$this->insert($postDetails);
        $postDetails['id'] = $postId;
        return $postDetails;
    }

    public function updatePost(int $postId, array $newPostDetails): array
    {
        $this->update($postId, $newPostDetails);
        return $this->findPost($postId);
    }

    public function deletePost($id){
        $post = $this->findPost($id);
        $this->delete($post);
    }
}

In this class, we define the model functions that allow our API to interact with the database. Start by specifying the table name and the columns that can be updated in the database.

The functions getAllPosts, getPost, savePost, updatePost, and deletePost let the API perform read or write operations as needed by the controller.

Creating a controller

Next, create a file name Post.php in the app/Controllers directory. Add:

<?php

namespace app\Controllers;

use CodeIgniter\HTTP\ResponseInterface;
use PostModel;

class Post extends BaseController
{
    public function create()
    {
        $rules = [
            'title' => 'required|min_length[6]|max_length[100]',
            'author' => 'required|min_length[6]|max_length[100]',
            'content' => 'required|min_length[6]|max_length[1000]',
        ];

        $input = $this->getRequestInput($this->request);

        if (!$this->validateRequest($input, $rules)) {
            return $this
                ->getResponse(
                    $this->validator->getErrors(),
                    ResponseInterface::HTTP_BAD_REQUEST
                );
        }

        $model = new PostModel();
        $savePostResponse = $model->savePost($input);
        return $this->getResponse(
            [
                'message' => 'Post added successfully',
                'post' => $savePostResponse
            ],
            ResponseInterface::HTTP_CREATED
        );
    }

    public function index()
    {
        $model = new PostModel();
        return $this->getResponse([
            'message' => 'Posts retrieved successfully',
            'posts' => $model->getAllPosts()
        ]);
    }

    public function show($id)
    {
        $model = new PostModel();
        return $this->getResponse([
            'message' => 'Post retrieved successfully',
            'post' => $model->findPost($id)
        ]);
    }

    public function update($id)
    {
        $input = $this->getRequestInput($this->request);
        $model = new PostModel();
        $updatePostResponse = $model->updatePost($id, $input);

        return $this->getResponse(
            [
                'message' => 'Post updated successfully',
                'post' => $updatePostResponse
            ]
        );
    }

    public function delete($id){
        $model = new PostModel();
        $model->deletePost($id);

        return $this->getResponse(
            [
                'message' => 'Post deleted successfully',
            ]
        );
    }
}

This controller class contains five functions that correspond to routes for the user to:

  • create: index function
  • read: index and show functions
  • update: update function
  • delete: delete function

For each of these functions, we use the relevant function declared in PostModel.php to interact with the database.

Our controller makes use of some helper functions that need to be declared in the BaseController. Open the app/Controllers/BaseController.php file. Within the class BaseController, add:

public function getResponse(array $responseBody,
                            int $code = ResponseInterface::HTTP_OK)
{
    return $this
        ->response
        ->setStatusCode($code)
        ->setJSON($responseBody);
}

public function getRequestInput(IncomingRequest $request)
{
    $input = $request->getPost();
    if (empty($input)) {
        $input = json_decode($request->getBody(), true);
    }
    return $input;
}

public function validateRequest($input, array $rules, array $messages = [])
{
    $this->validator = Services::Validation()->setRules($rules);
    // If you replace the $rules array with the name of the group
    if (is_string($rules)) {
        $validation = config('Validation');

        // If the rule wasn't found in the \Config\Validation, we
        // should throw an exception so the developer can find it.
        if (!isset($validation->$rules)) {
            throw ValidationException::forRuleNotFound($rules);
        }

        // If no error message is defined, use the error message in the Config\Validation file
        if (!$messages) {
            $errorName = $rules . '_errors';
            $messages = $validation->$errorName ?? [];
        }

        $rules = $validation->$rules;
    }
    return $this->validator->setRules($rules, $messages)->run($input);
}

Note: Don’t forget to add these import statements for CodeIgniter:

use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\Validation\Exceptions\ValidationException;
use Config\Services;

Controller routes

Open the app/Config/Routes.php file and add:

$routes->get('posts', 'Post::index');
$routes->post('post', 'Post::create');
$routes->get('post/(:num)', 'Post::show/$1');
$routes->post('post/(:num)', 'Post::update/$1');
$routes->delete('post/(:num)', 'Post::delete/$1');

These lines will assign each function declared in the Post controller to an endpoint that the user can send a HTTP Request to.

Running the application and testing the endpoints

Run the application:

$ php spark serve

Using Postman (or a similar application) we can verify that our application is working properly.

Fetching the list of blog posts

Fetch Posts

Retrieving the details of a blog post

Fetch Post

Writing tests

It looks like our endpoints work properly but we cannot be absolutely certain until we have tested for all possibilities. To be sure our application handles unexpected situations, we should write tests that simulate edge scenarios.

In the tests directory, create a folder called Controllers. In the tests/Controllers directory, create a class named PostTest.php and add:

<?php

namespace Controllers;

use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Test\FeatureTestCase;

class PostTest extends FeatureTestCase
{
    public function testCreateWithValidRequest()
    {
        $result = $this->post('post', [
            'title' => 'THis is a dummy post',
            'author' => 'Janet Doe',
            'content' => 'This is a test to show that the create endpoint works'
        ]);
        $result->assertStatus(ResponseInterface::HTTP_CREATED);
    }

    public function testIndex()
    {
        $result = $this->get('posts');
        $result->assertStatus(ResponseInterface::HTTP_OK);
    }

    public function testShowWithInvalidId()
    {
        $result = $this->get('post/0'); //no item in the database should have an id of -1
        $result->assertStatus(ResponseInterface::HTTP_NOT_FOUND);
    }
}

To save time, we are testing just 3 scenarios in this tutorial. First, we try to create a post and assert that the API handles it successfully and returns a 201 HTTP response code. The second test checks that the API returns a 200 response code when a request is made to get all posts. The last test is an edge case; it is a request for a post that does not exist on the database. We expect the system to return a 404 response, because there is no such post on our database.

Next, we need to make some changes to the app/Config/Database.php file. Modify the $tests array:

public $tests = [
   'hostname' => '',
   'username' => '',
   'password' => '',
   'database' => '',
   'DBDriver' => '',
];

With your test cases in place, you can add the CircleCI configuration.

Adding CircleCI configuration

In your project root directory, create a folder named .circleci. Add a file called config.yml to that directory.

$ mkdir .circleci

$ touch .circleci/config.yml

In .circleci/config.yml add:

version: 2
jobs:
  build:
    docker:
      - image: circleci/php:7.4-node-browsers

      - image: circleci/mysql:8.0.4

        auth:
          username: mydockerhub-user
          password: $DOCKERHUB_PASSWORD

        environment:
          MYSQL_ROOT_PASSWORD: rootpw
          MYSQL_DATABASE: test_db
          MYSQL_USER: user
          MYSQL_PASSWORD: passw0rd

    steps:
      - checkout

      - run: sudo apt update
      - run: sudo docker-php-ext-install zip

      - run:
          # Our primary container isn't MYSQL so run a sleep command until it is ready.
          name: Waiting for MySQL to be ready
          command: |
            for i in `seq 1 10`;
            do
              nc -z 127.0.0.1 3306 && echo Success && exit 0
              echo -n .
              sleep 1
            done
            echo Failed waiting for MySQL && exit 1

      - run:
          name: Install MySQL CLI
          command: |
            sudo apt-get install default-mysql-client

      # Download and cache dependencies
      - restore_cache:
          keys:
            # "composer.lock" can be used if it is committed to the repo
            - v1-dependencies-{{ checksum "composer.json" }}
            # fallback to using the latest cache if no exact match is found
            - v1-dependencies-

      - run: composer install -n --prefer-dist

      - save_cache:
          key: v1-dependencies-{{ checksum "composer.json" }}
          paths:
            - ./vendor

      - run:
          name: Create .env file and add db parameters
          command: |
            sudo cp env .env
            sudo chmod 777 .env
            sudo echo "" >> .env
            sudo echo "CI_ENVIRONMENT = development" >> .env
            sudo echo "" >> .env
            sudo echo "database.default.hostname = 127.0.0.1" >> .env
            sudo echo "database.default.database = test_db" >> .env
            sudo echo "database.default.username = user" >> .env
            sudo echo "database.default.password = rootpw" >> .env
            sudo echo "database.default.DBDriver = MySQLi" >> .env

      - run: phpdbg -qrr ./vendor/bin/phpunit tests/Controllers

There are a couple of things going on here. After specifying the CircleCI version, we specify the build job. This job has two key blocks. The docker build specifies the images we need for our build process to run successfully. In it, we load two Docker images, one for PHP (7.4) and another for MySQL. The auth and environment blocks define the authentication parameters that allow our CodeIgniter application to connect to the database.

Note: CodeIgniter has a minimum PHP version requirement of 7.2. By default, CircleCI sets the PHP version for the Docker image to 7.1. Be sure to change this or your builds will fail.

The steps block does several important things:

  1. Checkout the code from the repository
  2. Set up a Docker image
  3. Install MySQL
  4. Install composer dependencies
  5. Create a .env file and update the database parameters accordingly
  6. Run the test cases in the tests/Controllers folder. Refer to the docs to see why we’re using phpdbg.

Next, we need to set up a repository on GitHub and link the project to CircleCI. See this post for help pushing your project to GitHub.

Adding a project to CircleCI

Log into your CircleCI account. If you signed up with your GitHub account, all your repositories will be displayed on your project’s dashboard.

Next to your ci-test-circleci project click on Set Up Project.

Set up Project

CircleCI detects your configuration file and also gives you the option to use a default PHP configuration.

Click Use Existing Config and then Start Buildiing. Your first build process will start running. This build will fail. You will understand why later on.

Failed Build

Click build. You will see the job steps and the status of each job. Why did the build fail in the last step? It is because our application isn not passing all the tests.

Failed Build Details

This is an example of the beauty of continuous integration; you can catch bugs and issues beforei> they make it to production. The problem in this case is trivial, but you would get the same benefit using a more complex databases

It is time to make our pipeline build successfully by making our application pass the last test.

Our test is failing because our application throws an exception when a post cannot be found for the provided ID. When this happens, our application should catch the exception and return an error message in the response.

To do this, we need to make a few changes. First, create a directory named Exception in the app directory. In the app/Exception directory, create a class named ItemNotFoundException.php and add:

<?php

namespace app\Exception;

use Exception;

class ItemNotFoundException extends Exception
{

}

This class allows us handle only exceptions for items that could not be found in the database. If we need to, we can apply different logic for another exception (an AccessDeniedException, for example).

Next, use this exception in the findPost function that is located in the app/Models/PostModel.php class. Add:

public function findPost($id): array
{
    $post = $this
        ->asArray()
        ->where(['id' => $id])
        ->first();

    if (!$post) throw new ItemNotFoundException('Could not find post for specified ID');

    return $post;
}

Note: Don’t forget to add this import statement at the top of the file.

use App\Exception\ItemNotFoundException;

Change the show function in the app/Controllers/Post.php class:

public function show($id)
{
    $model = new PostModel();
    try {
        return $this->getResponse([
            'message' => 'Post retrieved successfully',
            'post' => $model->findPost($id)
        ]);
    } catch (ItemNotFoundException $e) {
        return $this->getResponse([
            'message' => $e->getMessage(),
        ],
            ResponseInterface::HTTP_NOT_FOUND);
    }
}

Note: Don’t forget to add the import statement..

use App\Exception\ItemNotFoundException;

Commit and push your changes.

$ git add .
$ git commit -m "Handle ItemNotFoundException"
$ git push origin main

Once the changes are pushed successfully, go back to your CircleCI dashboard. There is a new build process running. When it is completed, the status changes to Success.

Successful Build

Conclusion

In this tutorial, I have shown you how to set up a continuous integration pipeline for a CodeIgniter application using GitHub and CircleCI. While our application was a simple one, with minimal test coverage, we covered the key areas of pipeline configuration and feature testing in CodeIgniter.

Continuous integration truly shines when there is a strong testing culture reflected in the codebase. With used with test cases that determine how the application should respond to both expected and unexpected scenarios, continuous integration makes the process of adding (and deploying) new features much simpler. If all tests pass, the update gets deployed to the production servers. If not, the team is alerted and they can fix issues before they cause any downtime. Give continuous integration a try and make code base bottlenecks a thing of the past for your team!

The entire codebase for this tutorial is available on GitHub.

Happy coding!


Oluyemi is a tech enthusiast, programming freak, and a web development junkie who loves to embrace new technology.