Continuous integration for CodeIgniter APIs
Fullstack Developer and Tech Author
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.
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
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
andshow
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
Retrieving the details of a blog post
![Fetch Post]2020-10-07-ci-fetch-post.png{: .zoomable }
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:
- Checkout the code from the repository
- Set up a Docker image
- Install MySQL
- Install composer dependencies
- Create a
.env
file and update the database parameters accordingly - Run the test cases in the
tests/Controllers
folder. Refer to the docs to see why we’re usingphpdbg
.
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.
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.
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.
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
.
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!