Gitlab CI for Laravel project <PART II>

Thanh Polimi
10 min readApr 4, 2023

--

I. Prequisite

I strongly suggest that you should read my first post about Gitlab CI for busy first because it will provide with alot of information about :

  • Creating the first Laravel project image and push it into our gitlab registry
  • How to setup gitlab CI runner and write our first gitlab-ci.yaml file

This article is the later part of Part I that I wrote earlier. It also strongly recommended that you finish Part I before enjoying this Part II.

II. Building our Gitlab CI Pipeline

Continuing from part I, we have the Dockerfile for Laravel web application and it will be used in this part. But before that, please comment out the last line start-container.sh so that we don’t have to start our container each time running our pipeline.

FROM ubuntu:22.04

LABEL maintainer="Miichisoft"

ARG WWWGROUP=1000

ARG NODE_VERSION=18

ADD . /usr/src/app

WORKDIR /usr/src/app

ENV DEBIAN_FRONTEND noninteractive
ENV TZ=UTC


RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

RUN apt-get update \
&& apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python2 dnsutils \
&& curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x14aa40ec0831756756d7f66c4f4ea0aae5267a6c' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \
&& echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu jammy main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \
&& apt-get update \
&& apt-get install -y php8.2-cli php8.2-dev \
php8.2-pgsql php8.2-sqlite3 php8.2-gd \
php8.2-curl \
php8.2-imap php8.2-mysql php8.2-mbstring \
php8.2-xml php8.2-zip php8.2-bcmath php8.2-soap \
php8.2-intl php8.2-readline \
php8.2-ldap \
php8.2-msgpack php8.2-igbinary php8.2-redis php8.2-swoole \
php8.2-memcached php8.2-pcov php8.2-xdebug \
&& php -r "readfile('https://getcomposer.org/installer');" | php -- --install-dir=/usr/bin/ --filename=composer \
&& curl -sLS https://deb.nodesource.com/setup_$NODE_VERSION.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g npm \
&& curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /etc/apt/keyrings/yarn.gpg >/dev/null \
&& echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \
&& apt-get update \
&& apt-get install -y yarn \
&& apt-get install -y mysql-client \
&& apt-get -y autoremove \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.2

RUN composer require --dev squizlabs/php_codesniffer phpstan/phpstan nunomaduro/larastan


# Add local and global vendor bin to PATH.
ENV PATH ./vendor/bin:/composer/vendor/bin:/root/.composer/vendor/bin:/usr/local/bin:$PATH

RUN groupadd --force -g $WWWGROUP www
RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1000 www

COPY start-container.sh /usr/local/bin/start-container.sh
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY xdebug.ini /etc/php/8.2/cli/conf.d/97-xdebug.ini
COPY error_reporting.ini /etc/php/8.2/cli/conf.d/98-error_reporting.ini
COPY php.ini /etc/php/8.2/cli/conf.d/99-php.ini


RUN chmod +x /usr/local/bin/start-container.sh

EXPOSE 8000

# Comment the following line
# ENTRYPOINT ["start-container.sh"]

Let build our first application image using the following command

$ cd app
$ docker build -t <registry.example.com/group/project/image-name> .

Building this image is going to take a little bit of time so please be patient. When image is successfully built, we can push it to our gitlab registry using the following command

$ docker push <registry.example.com/group/project/image-name>

※Reference: https://docs.gitlab.com/ee/user/packages/container_registry/build_and_push_images.html

As we have already built our first docker images, pushed it to our registry and finished setting up to have a gitlab runner running on specific machine with docker, it is now time to build our Gitlab CI pipeline.

As being seen, our pipeline has 4 stages : preparation, build, test and deploy. gitlab-ci.yml should be like this :

stages:
- preparation
- build
- test
- deploy

Our initial image is the image that we built above, so please make sure it is defined in our gitlab-ci.yml file

stages:
- preparation
- build
- test
- deploy

image: <registry.example.com/group/project/image>:latest

a. Preparation Stage

During preparation stage, we need to make sure all necessary libraries are installed by composer and yarn. These installations need to be cached for later run speedup. We then also need to run some specific commands needed to :

  • make a copy of the .env.example file to create new .env
  • generate application key
  • clear the config
cache:
key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG"

composer:
stage: preparation
before_script:
- php -v
- composer --version
- composer update
script:
- composer install --prefer-dist --no-ansi --no-interaction --no-progress --no-scripts
- cp .env.example .env
- php artisan key:generate
- php artisan config:clear
cache:
# The variable CI_COMMIT_REF_SLUG
# refers to the slug of the branch.
# For example: `master` for the master branch.
# We use the CI_JOB_NAME prefix to avoid conflicts with
# the `npm` cache that we'll define next.
- key: $CI_JOB_NAME-$CI_COMMIT_REF_SLUG-COMPOSER
# Define what to cache.
paths:
- vendor/
- key:
files:
- composer.lock
artifacts:
expire_in: 1 days
paths:
- vendor/
- .env

yarn:
# Same stage as `composer` so that they run in parallel.
stage: preparation
# Cache the `node_modules` folder
# using the `CI_JOB_NAME` suffix on the key.
cache:
- key: $CI_JOB_NAME-$CI_COMMIT_REF_SLUG-YARN
paths:
- node_modules/
- key:
files:
- yarn.lock
before_script:
- yarn --version
# Install and compile.
script:
- yarn --install --pure-lockfile
artifacts:
expire_in: 1 days
paths:
- node_modules/

b. Build Stage

After installation, we start building our application project. Please notice that here we use dependency keyword to restrict which artifacts are passed to a our job by providing a list of jobs to fetch artifacts from. We then created new artifacts from each job for being used in later jobs.

# Build Stages
yarn-build:
stage: build
dependencies:
- yarn
- composer
before_script:
- yarn --version
script:
- yarn run build
artifacts:
paths:
- public/
- node_modules/
expire_in: 1 days
when: always

composer-build:
stage: build
dependencies:
- composer
- yarn
before_script:
- php -v
- composer --version
script:
- php artisan config:clear
- php artisan cache:clear
- php artisan config:cache
- composer dump-autoload
artifacts:
paths:
- vendor/
- .env
expire_in: 1 days
when: always

c. Build Stage >> ESLint

To use ESLint to check coding our source code, first we need to install eslint and eslint-vue library:

$ yarn add -D eslint eslint-plugin-vue

In package.json let define a new script for eslint :

{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"eslint": "eslint --ext .js,.vue resources/js/"
},
"devDependencies": {
"@inertiajs/vue3": "^1.0.0",
"@tailwindcss/forms": "^0.5.3",
"@vitejs/plugin-vue": "^4.0.0",
"autoprefixer": "^10.4.12",
"axios": "^1.1.2",
"eslint": "^8.36.0",
"eslint-plugin-vue": "^9.9.0",
"laravel-vite-plugin": "^0.7.2",
"lodash": "^4.17.19",
"postcss": "^8.4.18",
"tailwindcss": "^3.2.1",
"vite": "^4.0.0",
"vue": "^3.2.41"
}
}

Let config eslint using .eslintrc.js file for our Laravel project. Here I added some globals so that eslint will not complaint about undefined variables like Ziggy, route,….I also turn off multi-word-component-names and some other rules that Laravel Vue does not comply from the begining.

module.exports = {
root: true,
extends: [
"eslint:recommended",
"plugin:vue/essential",
],
globals: {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly",
"Vue": true,
"EventBus": true,
"axios": true,
"_": true,
"tinymce": true,
"Vuex": true,
"preview": true,
"Ziggy": true,
"route": true
},
plugins: [
"vue"
],
env: {
browser: true,
es6: true,
node: true
},
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module'
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'vue/no-multiple-template-root': 'off',
'vue/multi-word-component-names': 'off',
'vue/no-v-model-argument':'off'
}
};

It is now time to define our eslint job . This job only depends on yarn-build job.

# Test Stages
linter:
stage: test
dependencies:
- yarn-build
before_script:
- yarn --version
script:
- yarn run eslint

Of course we can test our configuration any time we want by running directly from our source code folder:

❯ yarn run eslint
yarn run v1.22.19
$ eslint --ext .js,.vue resources/js/
✨ Done in 1.16s.

c. Build Stage >> PHP CodeSniffer

We have already installed PHP Codesniffer while building our images. I think it should be better to install PHP Codesniffer as global using composer require global squizlabs/php_codesnifferor we can use before_script to install it just before running phpcs at this job but for now let continue with our pre-built image. Our job will be defined like below to check our code against PSR-12 convention:

php-codesniffer:
stage: test
dependencies:
- yarn-build
- composer-build
script:
- ./vendor/bin/phpcs --standard=PSR12 --extensions=php app

c. Build Stage >> PHPStan

PHPStan scans your whole codebase and looks for both obvious & tricky bugs. Even in those rarely executed if statements that certainly aren’t covered by tests. You can run it on your machine and in CI to prevent those bugs ever reaching your customers in production.

For Laravel, I decided to use PHPStan and LaraStan (※Larastan based on PHPStan and focuses on finding errors in your code. It catches whole classes of bugs even before you write tests for the code).

phpstan:
stage: test
interruptible: true
dependencies:
- yarn-build
- composer-build
script:
- ./vendor/bin/phpstan analyse --memory-limit=4G -c phpstan.neon --no-progress --error-format gitlab > phpstan-report.json
cache:
- key: $CI_COMMIT_REF_SLUG-PHPSTAN
paths:
- phpstan-result-cache
policy: pull-push
when: always
- key:
files:
- composer.lock
paths:
- vendor/
policy: pull
rules:
- changes:
- '.gitlab-ci.yml'
- 'phpstan.neon'
- 'phpstan-baseline.neon'
- 'composer.lock'
- 'app/**/*.php'
- 'app/*.php'
- 'tests/**/*.php'
- 'tests/*.php'
- 'config/**/*.php'
- 'config/*.php'
artifacts:
when: always
reports:
codequality: phpstan-report.json

This job depend on artifacts provided by yarn-build and composer-build job. We are going to run check on our source code and export the check result in gitlab format which will be extremely useful for gitlab to display in case of merge request. Checking are performed using our configuration file phpstan.neon

includes:
- vendor/nunomaduro/larastan/extension.neon
- phpstan-baseline.neon
parameters:
level: 5
paths:
- app
tmpDir: phpstan-result-cache
excludePaths:
- %currentWorkingDirectory%/app/Providers/*
ignoreErrors:

Laravel has some in-comply codes in the core, and because we don’t want to change laravel’s core library we either exclude them from checking process using excludePathsor doing a step called baseline step :

$ ./vendor/bin/phpstan analyse --memory-limit=25 --generate-base

This will create a phpstan-baseline.neon and adds all current in-comply code as ignorances

parameters:
ignoreErrors:
-
message: "#^Parameter \\#1 \\$user of class Illuminate\\\\Auth\\\\Events\\\\Verified constructor expects Illuminate\\\\Contracts\\\\Auth\\\\MustVerifyEmail, App\\\\Models\\\\User\\|null given\\.$#"
count: 1
path: app/Http/Controllers/Auth/VerifyEmailController.php

c. Build Stage >> PHP Unit

Finally, it is time to run unit test job in test stage. Laravel comes with PHPUnit inside, and all we need to do is running the following command

$ php artisan test

But the problem here is part of unit test case need database read/write access. Our php unit test job need database read/write support. To do this, first we define some variables

variables:
MYSQL_ROOT_PASSWORD: "password"
MYSQL_USER: "user"
MYSQL_PASSWORD: "password"
MYSQL_DATABASE: "sample"
MYSQL_HOST: "mysql"

Then we need seeding job to create some sample data

db-seeding:
stage: build
services:
- name: mysql:8.0
command: [ "--default-authentication-plugin=mysql_native_password" ]
# Download the artifacts for these jobs
dependencies:
- composer
- yarn
- composer-build
before_script:
- mysql --version
script:
- php artisan migrate:fresh --seed
- mysqldump --host="${MYSQL_HOST}" --user="${MYSQL_USER}" --password="${MYSQL_PASSWORD}" "${MYSQL_DATABASE}" > db.sql
artifacts:
paths:
- storage/logs # for debugging
- db.sql
expire_in: 1 days
when: always

Please being noticed the tip here that we use services mysql:8.0 here as DBMS to insert seeding data, then we export those data into db.sql file as artifact for being used later.

PHP Unit Job will be as follow

php-unit:
stage: test
services:
- name: mysql:8.0
command: ["--default-authentication-plugin=mysql_native_password"]
dependencies:
- yarn-build
- composer-build
- db-seeding
before_script:
- mysql --host="${MYSQL_HOST}" --user="${MYSQL_USER}" --password="${MYSQL_PASSWORD}" "${MYSQL_DATABASE}" < db.sql
script:
- php artisan test --log-junit phpunit-report.xml --coverage-cobertura phpunit-coverage.xml --do-not-cache-result --colors=never --coverage-text --no-interaction
artifacts:
when: always
reports:
junit: phpunit-report.xml
coverage_report:
coverage_format: cobertura
path: phpunit-coverage.xml

Before script, we import db.sql into our mysql services and make those seeding data available to phpunit job. Then we run php artisan test to run all the test case and create report in junit and cobetura format. Those files will be provided to gitlab reports mechanism.

III. Conclusion

The whole gitlab-ci.yml will be as bellow. Adding PHP Mess Detector will be as easy as PHPCodeSniffer so I will left this as a small exercise for those who are interested in. Please contact me if you have any trouble while following this article. Happy Coding!

stages:
- preparation
- build
- test
- deploy

image: registry.miichisoft.net/miichisoft/templates/template-project-laravel-9:latest

variables:
MYSQL_ROOT_PASSWORD: "password"
MYSQL_USER: "user"
MYSQL_PASSWORD: "password"
MYSQL_DATABASE: "sample"
MYSQL_HOST: "mysql"

cache:
key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG"

composer:
stage: preparation
before_script:
- php -v
- composer --version
- composer update
script:
- composer install --prefer-dist --no-ansi --no-interaction --no-progress --no-scripts
- cp .env.example .env
- php artisan key:generate
- php artisan config:clear
cache:
# The variable CI_COMMIT_REF_SLUG
# refers to the slug of the branch.
# For example: `master` for the master branch.
# We use the CI_JOB_NAME prefix to avoid conflicts with
# the `npm` cache that we'll define next.
- key: $CI_JOB_NAME-$CI_COMMIT_REF_SLUG-COMPOSER
# Define what to cache.
paths:
- vendor/
- key:
files:
- composer.lock
artifacts:
expire_in: 1 days
paths:
- vendor/
- .env

yarn:
# Same stage as `composer` so that they run in parallel.
stage: preparation
# Cache the `node_modules` folder
# using the `CI_JOB_NAME` suffix on the key.
cache:
- key: $CI_JOB_NAME-$CI_COMMIT_REF_SLUG-YARN
paths:
- node_modules/
- key:
files:
- yarn.lock
before_script:
- yarn --version
# Install and compile.
script:
- yarn --install --pure-lockfile
artifacts:
expire_in: 1 days
paths:
- node_modules/

# Build Stages
yarn-build:
stage: build
dependencies:
- yarn
- composer
before_script:
- yarn --version
script:
- yarn run build
artifacts:
paths:
- public/
- node_modules/
expire_in: 1 days
when: always

composer-build:
stage: build
dependencies:
- composer
- yarn
before_script:
- php -v
- composer --version
script:
- php artisan config:clear
- php artisan cache:clear
- php artisan config:cache
- composer dump-autoload
artifacts:
paths:
- vendor/
- .env
expire_in: 1 days
when: always

db-seeding:
stage: build
services:
- name: mysql:8.0
command: [ "--default-authentication-plugin=mysql_native_password" ]
# Download the artifacts for these jobs
dependencies:
- composer
- yarn
- composer-build
before_script:
- mysql --version
script:
- php artisan migrate:fresh --seed
- mysqldump --host="${MYSQL_HOST}" --user="${MYSQL_USER}" --password="${MYSQL_PASSWORD}" "${MYSQL_DATABASE}" > db.sql
artifacts:
paths:
- storage/logs # for debugging
- db.sql
expire_in: 1 days
when: always

# Test Stages
linter:
stage: test
dependencies:
- yarn-build
before_script:
- yarn --version
script:
- yarn run eslint

php-codesniffer:
stage: test
dependencies:
- yarn-build
- composer-build
script:
- ./vendor/bin/phpcs --standard=PSR12 --extensions=php app

php-unit:
stage: test
services:
- name: mysql:8.0
command: ["--default-authentication-plugin=mysql_native_password"]
dependencies:
- yarn-build
- composer-build
- db-seeding
before_script:
- mysql --host="${MYSQL_HOST}" --user="${MYSQL_USER}" --password="${MYSQL_PASSWORD}" "${MYSQL_DATABASE}" < db.sql
script:
- php artisan test --log-junit phpunit-report.xml --coverage-cobertura phpunit-coverage.xml --do-not-cache-result --colors=never --coverage-text --no-interaction
artifacts:
when: always
reports:
junit: phpunit-report.xml
coverage_report:
coverage_format: cobertura
path: phpunit-coverage.xml

phpstan:
stage: test
interruptible: true
dependencies:
- yarn-build
- composer-build
script:
- ./vendor/bin/phpstan analyse --memory-limit=4G -c phpstan.neon --no-progress --error-format gitlab > phpstan-report.json
cache:
- key: $CI_COMMIT_REF_SLUG-PHPSTAN
paths:
- phpstan-result-cache
policy: pull-push
when: always
- key:
files:
- composer.lock
paths:
- vendor/
policy: pull
rules:
- changes:
- '.gitlab-ci.yml'
- 'phpstan.neon'
- 'phpstan-baseline.neon'
- 'composer.lock'
- 'app/**/*.php'
- 'app/*.php'
- 'tests/**/*.php'
- 'tests/*.php'
- 'config/**/*.php'
- 'config/*.php'
artifacts:
when: always
reports:
codequality: phpstan-report.json

--

--

Thanh Polimi

I am represented by the lion(leo), and these spirited fire signs are the kings of the celestial jungle. Leo is delighted to embrace his royal status.