In this tutorial I'll show you how to create a PHP application that will connect to a MySQL database. To serve the php application, we'll use PHP-FPM and NGINX docker containers.
The purpose of this tutorial is to give you an idea on how to create the application with the dependencies it needs. For the real projects please use a framework like Symfony which will come loaded with all best practices.
Let's start with building the docker configuration for our project.
The application will need to know how to connect to the database, and also we'll need some configurations for each container on the runtime.
./docker/database-root.envThe database container requires to configure the ROOT user password. For this, we'll create a single environment file which will have only the password:
MYSQL_ROOT_PASSWORD=kisphp
./docker/database.envThe application will use a separate user to connect to the database. For this we'll create another file that we can share between the mysql docker container and the php-fpm container.
MYSQL_HOST variable is not necessary for the mysql container, but is necessary for the php container.
MYSQL_HOST=database
MYSQL_USER=kisphp_user
MYSQL_PASSWORD=kisphp_pass
MYSQL_DATABASE=kisphp_db
We'll include another container, so we can access the database directly, and so we can manage it easier if we want to. For this, we'll need to configure a phpmyadmin container that we'll expose on port 8080. But first, we create the configuration file for it.
./docker/phpmyadmin.envPMA_HOST=database
PMA_USER=root
PMA_PASSWORD=kisphp
PMA_ARBITRARY=1
./docker/mysql/DockerfileFor this tutorial, there is no need to define your own mysql docker image. But I'll keep the configurations here in case you need to make some changes to the mysql server, this is one simple way to do it.
FROM mysql:5.7
COPY mysqld.cnf /etc/mysql/mysql.conf.d/mysqld.conf
./docker/mysql/mysqld.cnfIn the next file, usually you can add extra parameters to tweak your mysql configurations to meet your requirements.
# The MySQL  Server configuration file.
#
# For explanations see
# http://dev.mysql.com/doc/mysql/en/server-system-variables.html
[mysqld]
pid-file	= /var/run/mysqld/mysqld.pid
socket		= /var/run/mysqld/mysqld.sock
datadir		= /var/lib/mysql
#log-error	= /var/log/mysql/error.log
# By default we only accept connections from localhost
#bind-address	= 127.0.0.1
# Disabling symbolic-links is recommended to prevent assorted security risks
symbolic-links=0
./docker/mysql/sql/10-schema.sqlOn local environments, you might want to have a database created with some data in it. We'll use this sql file to do exactly that. This way we'll have a database with two tables and some data in each of them that we can use in our demo application.
-- phpMyAdmin SQL Dump
-- version 5.1.1
-- https://www.phpmyadmin.net/
--
-- Host: database
-- Generation Time: Dec 25, 2021 at 11:17 AM
-- Server version: 5.7.36
-- PHP Version: 7.4.27
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
--
-- Table structure for table `offices`
--
CREATE TABLE `offices` (
   `id` int(10) UNSIGNED NOT NULL,
   `city` varchar(64) NOT NULL DEFAULT '',
   `phone` varchar(64) NOT NULL DEFAULT '',
   `address_line_1` varchar(255) NOT NULL DEFAULT '',
   `address_line_2` varchar(255) NOT NULL DEFAULT '',
   `state` varchar(64) NOT NULL DEFAULT '',
   `country` varchar(32) NOT NULL DEFAULT '',
   `postal_code` varchar(16) NOT NULL DEFAULT '',
   `territory` varchar(16) NOT NULL DEFAULT ''
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
--
-- Dumping data for table `offices`
--
INSERT INTO `offices` (`id`, `city`, `phone`, `address_line_1`, `address_line_2`, `state`, `country`, `postal_code`, `territory`) VALUES
(1, 'San Francisco', '+1 000 000 0001', '100 Market Street', 'Suite 300', 'CA', 'USA', '94080', 'USA'),
(2, 'Boston', '+1 000 000 0002', '1550 Court Place', 'Suite 102', 'MA', 'USA', '02107', 'USA'),
(3, 'NYC', '+1 000 000 0003', '523 East 53rd Street', 'apt. 5A', 'NY', 'USA', '10022', 'USA'),
(4, 'Paris', '+33 00 000 0001', '43 Rue Jouffroy D\'abbans', '', '', 'France', '75017', 'EMEA'),
(5, 'Tokyo', '+81 00 000 0001', '4-1 Kioicho', '', 'Chiyoda-Ku', 'Japan', '102-8578', 'Japan'),
(6, 'Sydney', '+61 0 0000 0001', '5-11 Wentworth Avenue', 'Floor #2', '', 'Australia', 'NSW 2010', 'APAC'),
(7, 'London', '+44 00 0000 0001', '25 Old Broad Street', 'Level 7', '', 'UK', 'EC2N 1HN', 'EMEA');
-- --------------------------------------------------------
--
-- Table structure for table `employees`
--
CREATE TABLE `employees` (
  `id` int(10) UNSIGNED NOT NULL,
  `id_office` int(10) UNSIGNED NOT NULL,
  `reports_to` int(10) UNSIGNED DEFAULT NULL,
  `first_name` varchar(64) NOT NULL DEFAULT '',
  `last_name` varchar(64) NOT NULL DEFAULT '',
  `email` varchar(255) NOT NULL DEFAULT '',
  `job_title` varchar(64) NOT NULL DEFAULT ''
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
--
-- Dumping data for table `employees`
--
INSERT INTO `employees` (`id`, `id_office`, `reports_to`, `first_name`, `last_name`, `email`, `job_title`) VALUES
(1002, 1, NULL, 'Diane', 'Murphy', 'dmurphy@example.com', 'President'),
(1056, 1, 1002, 'Mary', 'Patterson', 'mpatterso@example.com', 'VP Sales'),
(1076, 1, 1002, 'Jeff', 'Firrelli', 'jfirrelli@example.com', 'VP Marketing'),
(1088, 6, 1056, 'William', 'Patterson', 'wpatterson@example.com', 'Sales Manager (APAC)'),
(1102, 4, 1056, 'Gerard', 'Bondur', 'gbondur@example.com', 'Sale Manager (EMEA)'),
(1143, 1, 1056, 'Anthony', 'Bow', 'abow@example.com', 'Sales Manager (NA)'),
(1165, 1, 1143, 'Leslie', 'Jennings', 'ljennings@example.com', 'Sales Rep'),
(1166, 1, 1143, 'Leslie', 'Thompson', 'lthompson@example.com', 'Sales Rep'),
(1188, 2, 1143, 'Julie', 'Firrelli', 'jfirrelli@example.com', 'Sales Rep'),
(1216, 2, 1143, 'Steve', 'Patterson', 'spatterson@example.com', 'Sales Rep'),
(1286, 3, 1143, 'Foon Yue', 'Tseng', 'ftseng@example.com', 'Sales Rep'),
(1323, 3, 1143, 'George', 'Vanauf', 'gvanauf@example.com', 'Sales Rep'),
(1337, 4, 1102, 'Loui', 'Bondur', 'lbondur@example.com', 'Sales Rep'),
(1370, 4, 1102, 'Gerard', 'Hernandez', 'ghernande@example.com', 'Sales Rep'),
(1401, 4, 1102, 'Pamela', 'Castillo', 'pcastillo@example.com', 'Sales Rep'),
(1501, 7, 1102, 'Larry', 'Bott', 'lbott@example.com', 'Sales Rep'),
(1504, 7, 1102, 'Barry', 'Jones', 'bjones@example.com', 'Sales Rep'),
(1611, 6, 1088, 'Andy', 'Fixter', 'afixter@example.com', 'Sales Rep'),
(1612, 6, 1088, 'Peter', 'Marsh', 'pmarsh@example.com', 'Sales Rep'),
(1619, 6, 1088, 'Tom', 'King', 'tking@example.com', 'Sales Rep'),
(1621, 5, 1056, 'Mami', 'Nishi', 'mnishi@example.com', 'Sales Rep'),
(1625, 5, 1621, 'Yoshimi', 'Kato', 'ykato@example.com', 'Sales Rep'),
(1702, 4, 1102, 'Martin', 'Gerard', 'mgerard@example.com', 'Sales Rep');
-- --------------------------------------------------------
--
-- Indexes for table `employees`
--
ALTER TABLE `employees`
  ADD PRIMARY KEY (`id`),
  ADD KEY `employee_leader` (`reports_to`),
  ADD KEY `employee_office` (`id_office`);
--
-- Indexes for table `offices`
--
ALTER TABLE `offices`
  ADD PRIMARY KEY (`id`);
--
-- AUTO_INCREMENT for table `employees`
--
ALTER TABLE `employees`
  MODIFY `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1703;
--
-- AUTO_INCREMENT for table `offices`
--
ALTER TABLE `offices`
  MODIFY `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=8;
--
-- Constraints for table `employees`
--
ALTER TABLE `employees`
  ADD CONSTRAINT `employee_leader` FOREIGN KEY (`reports_to`) REFERENCES `employees` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
  ADD CONSTRAINT `employee_office` FOREIGN KEY (`id_office`) REFERENCES `offices` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
COMMIT;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
./docker/php/DockerfileLet's build the PHP-FPM container and add some tools to it so it will better work with our application.
FROM php:7.4-fpm
COPY . /app
WORKDIR /app
RUN apt-get update \
&& apt-get install -y zip unzip libzip-dev default-mysql-client \
&& docker-php-ext-install zip pdo_mysql
# Install composer for the application dependencies
RUN curl -sS https://getcomposer.org/installer | php \
&& mv composer.phar /bin/composer
# Install project dependencies
RUN composer install -noa
By default, php-fpm will expose port 9000. You'll see it defined in our docker-compose.yml file later.
./docker/nginx/DockerfileBesides the php-fpm container, we'll need the nginx container as well, since this one will take care of the requests.
The NGINX container needs actually only the public directory from the application which in our case is /app/public. For this, we'll start building our docker image from the php-fpm one and we'll just copy the files from the public directory, including the index.php file.
FROM php-fpm as base
# -----------------------------------------------------------
FROM nginx
ENV UNAME=web
RUN apt-get update \
&& apt-get install -y curl vim mc tree jq \
# Cleanup
&& apt-get clean \
&& apt-get autoremove -y --purge
# copy nginx virtual host configuration for the prokect
COPY ./docker/nginx/site.conf /etc/nginx/conf.d/default.conf
WORKDIR /app
# copy public directory from the php-fpm docker image loaded here as base
COPY --from=base /app/public /app/public
./docker/nginx/site.confThis is the file where you can configure your nginx server
server {
    # listen on all requests on port 80
    listen 80;
    # all hosts requests matches this application
    server_name _;
    # here is the public directory of your website (the application itself will be on /app directory)
    root /app/public;
    location / {
        try_files $uri /index.php$is_args$args;
    }
    # handle requests for images
    location ~* \.(jpg|jpeg|png|gif) {
        try_files $uri /index.php$is_args$args;
    }
    # handle requests for php files
    location ~ \.php$ {
        # pass the reuqest to the PHP-FPM container called "php-fpm" or port 9000
        fastcgi_pass php-fpm:9000;
        fastcgi_split_path_info ^(.+\.php)(/.*)$;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
    }
}
./composer.jsonSince we use PHP, we'll use composer as a dependency manager and for this tutorial we'll require only two dependencies: twig/twig for the templates and kisphp/dbal for the database connection. There an optional third dependency for development only symfony/var-dumper.
{
    "require": {
        "twig/twig": "^3.3",
        "kisphp/dbal": "^0.2.0"
    },
    "require-dev": {
        "symfony/var-dumper": "^5.4"
    }
}
./public/index.phpThis is the front controller of our application where we put all together:
<?php
// use statements for dependencies classes
use Doctrine\DBAL\Configuration;
use Doctrine\DBAL\DriverManager;
use Kisphp\Db\KisphpDbal;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
// load autoloader
require_once __DIR__ . '/../vendor/autoload.php';
// Configure database connection ..
$config = new Configuration();
$parameteres = [
    'driver' => 'pdo_mysql',
    'host' => getenv('MYSQL_HOST'),
    'dbname' => getenv('MYSQL_DATABASE'),
    'user' => getenv('MYSQL_USER'),
    'password' => getenv('MYSQL_PASSWORD'),
    'charset'  => 'utf8',
    'driverOptions' => [
        1002 => 'SET NAMES utf8',
    ]
];
$connection = DriverManager::getConnection($parameteres, $config);
$kisphpDbal = new KisphpDbal($connection);
// .. end database connection
// Configure template engine ..
$loader = new FilesystemLoader(__DIR__ . '/../templates');
$twig = new Environment($loader);
// .. end template configuration
// Run a database query to get data from database
$query = $kisphpDbal->query("SELECT e.id, e.first_name, e.last_name, CONCAT(ee.first_name, ' ', ee.last_name) as reports_to_name, e.reports_to, e.job_title, o.city as office
FROM employees AS e
LEFT JOIN offices AS o ON (o.id = e.id_office)
LEFT JOIN employees AS ee ON (ee.id = e.reports_to)
");
/** @var User $tree */
$tree = [];
// load template and show data from database
echo $twig->render('homepage.twig', [
    'rows' => $query->fetchAllAssociative(),
]);
./templates/homepage.twigCreate the homepage template that will extend the main layout of our small website.
{% extends 'layout.twig' %}
{% block body %}
<table class="table table-condensed">
    <tr>
        <th>Name</th>
        <th>Office</th>
        <th>Reports To</th>
        <th>Job Title</th>
    </tr>
    {% for row in rows %}
    <tr>
        <td>{{ row.first_name }} {{ row.last_nName }}</td>
        <td>{{ row.office }}</td>
        <td>{{ row.reports_to }}</td>
        <td>{{ row.job_title }}</td>
    </tr>
    {% endfor %}
</table>
{% endblock %}
./templates/layout.twigCreate the layout template and load Bootstrap as frontend styling.
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
</head>
<body>
<div class="container">
{% block body %}
this block will be overwritten by the block defined in `homepage.twig` file
{% endblock %}
</div>
</body>
</html>
./docker-compose.ymlNow, that we have all the files configured, let's write our docker-compose file to glue all of them together.
version: '3'
services:
  database:
    # we build our own docker image with custom configurations
    build:
      context: docker/mysql
    # or we use the default mysql docker image with the default configuration
#    image: mysql:5.7
    # expose port 3306 so we can connect to the server with desktop applications like MySQL Workbench, DBeaver, Heidi SQL, etc
    ports:
    - 3306:3306
    env_file:
      - docker/database-root.env  # load the root user password (user root with this password will have full privileges)
      - docker/database.env       # load application specific user and database 
    volumes:
      - ./_data/mysql:/var/lib/mysql # create a local volume for the database storage so you won't loose the data if you delete the docker container
      - ./docker/mysql/sql/:/docker-entrypoint-initdb.d/ # load the sql files from here on database creation
  phpmyadmin:
    image: phpmyadmin
    restart: always
    ports:
      - 8080:80 # open http://localhost:8080 in your browser to access phpmyadmin console
    links:
      - database
    env_file:
      - docker/phpmyadmin.env # load environment variables necessary to create the connection to the database container
  php-fpm:
    # for your php application you need to create a docker image
    build:
      dockerfile: docker/php/Dockerfile
      context: "."
    image: php-fpm
    env_file:
      - docker/database.env # load database configurations to be able to connect to the database
      # here you can also have more files with env variables for specific purposes
    ports:
    - 9000
    volumes:
    - ./:/app # on local development you need to create this volume to load your local project so you can keep making changes to it
    # keep in mind, that the Dockerfile will copy the local project into docker context, will build the application and it will store it in the docker image, but with this option here, you'll overwrite the directory with the application from the container.
  php-nginx:
    build:
      dockerfile: docker/nginx/Dockerfile
      context: "."
    image: php-nginx
    ports:
    - 80:80 # open http://localhost in your browser to see the application running
    volumes:
    - ./:/app
./.gitignoreWhen you use git to store your project in a repository, you need to ignore some files that gets generated.
vendor
_data
./MakefileWe'll create a makefile to ease our work and to have some small automation on local environment
.PHONY: up clean
up:
	composer install -noa
	docker-compose up -d --build
clean:
	docker-compose stop
	docker-compose rm -f
	rm -rf _data/ vendor/
Once you have all those files created, run the following command to start the project:
make up
To stop and delete the docker containers, run the clean command
make clean