57.15 Project 15: PHP with MySQL and with MongoDB (one API, two databases)
Why: the problem
Suppose we build the product catalogue API for a Pune electronics shop that sells on Amazon and Flipkart, and the team is not sure which database to pick. The orders and prices need strict tables and transactions, so MySQL fits. But each product has different fields: a phone has RAM and camera, a fridge has litres and star rating. That shape fits MongoDB better. Installing both MySQL and MongoDB plus the PHP mongodb extension on every laptop is slow, and the mongodb extension often fails to compile. With Docker we build one PHP image that has both pdo_mysql and the mongodb extension, use Composer inside a container, and start either database with its own Compose file. The same API code talks to MySQL or MongoDB through one small interface, chosen by one environment variable, so we can compare both honestly.
What: every term in plain English
- pdo_mysql — The PHP extension that lets PDO talk to MySQL.
- mongodb extension — The low-level PHP driver for MongoDB, installed with pecl.
- mongodb/mongodb — The Composer library on top of the extension. It gives easy classes like MongoDB\Client.
- pecl — The PHP tool that downloads and builds extensions not shipped with PHP.
- Composer — The PHP package manager. composer.json lists the libraries; composer install downloads them into vendor/.
- Interface — A PHP contract (ProductStore) with list() and add(). MySQL and Mongo classes both follow it.
- ObjectId — The 24-character id MongoDB gives each document.
- JSON column — A MySQL column type that stores flexible fields like specs.
How: step by step
- Build a PHP image with both drivers. pdo_mysql, pecl mongodb, Composer.
- Write one API with two stores. ProductStore interface.
- Run with MySQL. compose.mysql.yaml.
- Run with MongoDB. compose.mongo.yaml, same image.
- Compare queries. JSON column vs documents.
- Back up both. mysqldump and mongodump.
- Clean up. down -v.
Architecture
Folder layout
php-db/
Dockerfile
composer.json
composer.lock
compose.mysql.yaml
compose.mongo.yaml
mysql-init.sql
.env
public/index.php
src/
ProductStore.php
MysqlStore.php
MongoStore.php
Example 1: One PHP image with pdo_mysql, mongodb and Composer
Goal. One PHP image with pdo_mysql, mongodb and Composer.
Dockerfile
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --ignore-platform-req=ext-mongodb
FROM php:8.3-cli-alpine
RUN apk add --no-cache --virtual .build $PHPIZE_DEPS openssl-dev \
&& pecl install mongodb-1.20.0 \
&& docker-php-ext-enable mongodb \
&& docker-php-ext-install pdo_mysql \
&& apk del .build
WORKDIR /app
COPY --from=vendor /app/vendor ./vendor
COPY src/ ./src/
COPY public/ ./public/
RUN adduser -D -u 10001 app
USER app
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s CMD wget -qO- http://localhost:8000/health || exit 1
CMD ["php", "-S", "0.0.0.0:8000", "-t", "public", "public/index.php"]
composer.json
{
"name": "pune-electronics/catalogue",
"require": {
"php": ">=8.2",
"mongodb/mongodb": "^1.20"
},
"autoload": { "psr-4": { "Shop\\": "src/" } }
}
What each line does
FROM composer:2 AS vendor— A build stage with Composer. vendor/ is copied out; Composer itself never ships.--ignore-platform-req=ext-mongodb— The composer image has no mongodb extension; the final image does.$PHPIZE_DEPS— Compiler tools the official image lists for building extensions. We delete them after, to keep the image small.pecl install mongodb-1.20.0— Builds the MongoDB driver at a pinned version.docker-php-ext-enable mongodb— Writes the ini line that loads the extension.php -S ... public/index.php— PHP's built-in web server with a router file. Good for an API demo; use FPM + nginx (Project 14) under heavy load.psr-4 Shop\— Classes in src/ load automatically by name, like Shop\MysqlStore.
Green output
$ docker run --rm -v "$PWD":/app -w /app composer:2 composer update --ignore-platform-req=ext-mongodb
Lock file operations: 3 installs
- Locking mongodb/mongodb (1.20.0)
$ docker build -t shop-php:1.0 .
=> naming to docker.io/library/shop-php:1.0
$ docker run --rm shop-php:1.0 php -m | grep -E 'mongodb|pdo_mysql'
mongodb
pdo_mysql
Expected result. composer.lock is made by a throwaway container, and one image carries both database drivers.
Example 2: One API, two stores behind one interface
Goal. One API, two stores behind one interface.
src/ProductStore.php
<?php
namespace Shop;
interface ProductStore {
public function list(): array;
public function add(string $name, int $price, array $specs): string;
}
src/MysqlStore.php
<?php
namespace Shop;
use PDO;
final class MysqlStore implements ProductStore {
private PDO $pdo;
public function __construct(string $dsn, string $user, string $pass) {
$this->pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
}
public function list(): array {
$rows = $this->pdo->query('SELECT id, name, price, specs FROM products ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as &$r) { $r['id'] = (string) $r['id']; $r['price'] = (int) $r['price']; $r['specs'] = json_decode($r['specs'], true); }
return $rows;
}
public function add(string $name, int $price, array $specs): string {
$st = $this->pdo->prepare('INSERT INTO products (name, price, specs) VALUES (?, ?, ?)');
$st->execute([$name, $price, json_encode($specs)]);
return $this->pdo->lastInsertId();
}
}
src/MongoStore.php
<?php
namespace Shop;
use MongoDB\Client;
final class MongoStore implements ProductStore {
private \MongoDB\Collection $col;
public function __construct(string $url) {
$this->col = (new Client($url))->selectCollection('shop', 'products');
}
public function list(): array {
$out = [];
foreach ($this->col->find([], ['sort' => ['_id' => 1]]) as $d) {
$out[] = ['id' => (string) $d['_id'], 'name' => $d['name'], 'price' => $d['price'], 'specs' => $d['specs']->getArrayCopy()];
}
return $out;
}
public function add(string $name, int $price, array $specs): string {
$res = $this->col->insertOne(['name' => $name, 'price' => $price, 'specs' => $specs]);
return (string) $res->getInsertedId();
}
}
public/index.php
<?php
require __DIR__ . '/../vendor/autoload.php';
use Shop\MysqlStore;
use Shop\MongoStore;
header('Content-Type: application/json');
$store = getenv('DB_KIND') === 'mongo'
? new MongoStore(getenv('MONGO_URL'))
: new MysqlStore(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'));
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($path === '/health') { echo json_encode(['status' => 'ok', 'db' => getenv('DB_KIND')]); return; }
if ($path === '/products' && $_SERVER['REQUEST_METHOD'] === 'GET') { echo json_encode($store->list()); return; }
if ($path === '/products' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$in = json_decode(file_get_contents('php://input'), true) ?? [];
if (empty($in['name']) || !isset($in['price'])) { http_response_code(400); echo json_encode(['error' => 'name and price required']); return; }
http_response_code(201);
echo json_encode(['id' => $store->add($in['name'], (int) $in['price'], $in['specs'] ?? [])]);
return;
}
http_response_code(404);
echo json_encode(['error' => 'no route']);
What each line does
interface ProductStore— Both classes promise list() and add(), so index.php never cares which database is used.id returned as string— MySQL gives 1, 2, 3; MongoDB gives an ObjectId. Turning both into strings keeps the JSON the same shape.specs JSON column— In MySQL the flexible fields are stored as JSON text and decoded on read.insertOne— MongoDB stores specs as a real nested document.DB_KIND— One variable picks mongo or mysql at start.prepare / execute— Prepared statement for MySQL; the Mongo library escapes values itself.
Green output
$ find src public -name '*.php'
public/index.php
src/MongoStore.php
src/MysqlStore.php
src/ProductStore.php
$ docker run --rm -v "$PWD":/app -w /app shop-php:1.0 php -l src/MongoStore.php
No syntax errors detected in src/MongoStore.php
Expected result. Four small files, checked with php -l inside the image before we start any database.
Example 3: Run with MySQL
Goal. Run with MySQL.
compose.mysql.yaml
services:
api:
build: .
image: shop-php:1.0
ports: ["8000:8000"]
environment:
DB_KIND: mysql
DB_DSN: mysql:host=db;dbname=shop;charset=utf8mb4
DB_USER: shop
DB_PASS: ${DB_PASS}
depends_on:
db: { condition: service_healthy }
db:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
MYSQL_DATABASE: shop
MYSQL_USER: shop
MYSQL_PASSWORD: ${DB_PASS}
volumes:
- mysql-data:/var/lib/mysql
- ./mysql-init.sql:/docker-entrypoint-initdb.d/01.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${DB_ROOT_PASS}"]
interval: 5s
retries: 20
volumes:
mysql-data:
mysql-init.sql
CREATE TABLE products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL,
price INT NOT NULL,
specs JSON NOT NULL
);
INSERT INTO products (name, price, specs) VALUES
('Redmi Note 13', 17999, JSON_OBJECT('ram_gb', 8, 'camera_mp', 108));
.env
DB_ROOT_PASS=change-me-root
DB_PASS=change-me-app
MONGO_PASSWORD=change-me-mongo
What each line does
DB_KIND: mysql— index.php creates MysqlStore.specs JSON— MySQL 8 checks that the value is valid JSON.JSON_OBJECT— Builds a JSON value inside SQL for the seed row.-p mysql (project name)— We give each run its own project name so both stacks never mix.
Green output
$ docker compose -f compose.mysql.yaml -p shop-mysql up -d --build --wait
Container shop-mysql-db-1 Healthy
Container shop-mysql-api-1 Healthy
$ curl -s -X POST localhost:8000/products -H 'Content-Type: application/json' -d '{"name":"LG 260L Fridge","price":28990,"specs":{"litres":260,"stars":3}}'
{"id":"2"}
$ curl -s localhost:8000/products
[{"id":"1","name":"Redmi Note 13","price":17999,"specs":{"ram_gb":8,"camera_mp":108}},{"id":"2","name":"LG 260L Fridge","price":28990,"specs":{"litres":260,"stars":3}}]
$ docker compose -p shop-mysql exec db mysql -ushop -pchange-me-app shop -e "SELECT name, specs->>'$.stars' AS stars FROM products"
name stars
Redmi Note 13 NULL
LG 260L Fridge 3
Expected result. MySQL stores the flexible specs in a JSON column and can still query one field.
Example 4: Run the same image with MongoDB
Goal. Run the same image with MongoDB.
compose.mongo.yaml
services:
api:
image: shop-php:1.0
ports: ["8000:8000"]
environment:
DB_KIND: mongo
MONGO_URL: mongodb://root:${MONGO_PASSWORD}@mongo:27017/?authSource=admin
depends_on:
mongo: { condition: service_healthy }
mongo:
image: mongo:7
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
volumes: [mongo-data:/data/db]
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 5s
retries: 20
volumes:
mongo-data:
What each line does
image: shop-php:1.0 (no build)— The exact same image as the MySQL run. Only the variables change.MONGO_URL— Connection string with user, password, host mongo and authSource=admin.mongosh ping— Health check, so the API starts after MongoDB is ready.No init script needed— MongoDB creates the shop database and products collection on the first insert.
Green output
$ docker compose -f compose.mysql.yaml -p shop-mysql down
$ docker compose -f compose.mongo.yaml -p shop-mongo up -d --wait
Container shop-mongo-mongo-1 Healthy
Container shop-mongo-api-1 Healthy
$ curl -s -X POST localhost:8000/products -H 'Content-Type: application/json' -d '{"name":"Samsung Galaxy M35","price":19999,"specs":{"ram_gb":8,"battery_mah":6000}}'
{"id":"671b4c2a9e1f0b2a3c4d5e60"}
$ curl -s localhost:8000/health
{"status":"ok","db":"mongo"}
$ docker compose -p shop-mongo exec mongo mongosh -u root -p change-me-mongo --quiet --eval 'db.getSiblingDB("shop").products.find({"specs.ram_gb": 8}, {name: 1, _id: 0})'
[ { name: 'Samsung Galaxy M35' } ]
Expected result. Same code and image; MongoDB returns a text ObjectId, and we can query nested specs directly.
Every command, step by step (plain docker and Compose)
-
Make folders
mkdir -p php-db/src php-db/public && cd php-dbWhat it does: Code in src/, the entry file in public/.
(no output) -
Make composer.lock
docker run --rm -v "$PWD":/app -w /app composer:2 composer update --ignore-platform-req=ext-mongodbWhat it does: Composer runs in a container; no PHP on the laptop.
Writing lock file -
Build the image
docker build -t shop-php:1.0 .What it does: Both drivers compiled in.
naming to docker.io/library/shop-php:1.0 -
Check drivers
docker run --rm shop-php:1.0 php -m | grep -E 'mongodb|pdo_mysql'What it does: Both must be listed.
mongodb pdo_mysql -
Start MySQL stack
docker compose -f compose.mysql.yaml -p shop-mysql up -d --waitWhat it does: API and MySQL.
Container shop-mysql-api-1 Healthy -
Add a product
curl -s -X POST localhost:8000/products -H 'Content-Type: application/json' -d '{"name":"LG 260L Fridge","price":28990,"specs":{"litres":260}}'What it does: Number id from MySQL.
{"id":"2"} -
Bad input
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8000/products -d '{}'What it does: Validation in index.php.
400 -
Switch to Mongo
docker compose -f compose.mysql.yaml -p shop-mysql down docker compose -f compose.mongo.yaml -p shop-mongo up -d --waitWhat it does: Same image, other database.
Container shop-mongo-api-1 Healthy -
Add in Mongo
curl -s -X POST localhost:8000/products -H 'Content-Type: application/json' -d '{"name":"Samsung Galaxy M35","price":19999,"specs":{"ram_gb":8}}'What it does: ObjectId as text.
{"id":"671b4c2a9e1f0b2a3c4d5e60"} -
Back up Mongo
docker compose -p shop-mongo exec mongo mongodump -u root -p change-me-mongo --authenticationDatabase admin --db shop --archive > shop.archiveWhat it does: A mongodump archive on the host.
ls -l shop.archive -
See logs
docker compose -p shop-mongo logs api --tail 3What it does: PHP built-in server logs each request.
[200]: GET /products -
Clean up
docker compose -f compose.mongo.yaml -p shop-mongo down -v docker compose -f compose.mysql.yaml -p shop-mysql down -v docker image rm shop-php:1.0What it does: Removes both stacks, volumes and the image.
Volume shop-mongo_mongo-data Removed
When it is red 1
Class "MongoDB\Client" not found
Cause. vendor/ was not copied or autoload.php was not required.
Fix, step by step
- Check COPY --from=vendor /app/vendor ./vendor in the Dockerfile.
- Make sure index.php requires vendor/autoload.php.
When it is red 2
mongodb/mongodb requires ext-mongodb ^1.20 -> it is missing from your system
Cause. Composer ran in an image without the mongodb extension.
Fix, step by step
- Add --ignore-platform-req=ext-mongodb in the composer stage.
- The final image still installs the real extension with pecl.
When it is red 3
configure: error: Cannot find OpenSSL's libraries
Cause. pecl build needs OpenSSL headers.
Fix, step by step
- Add openssl-dev to the apk add line.
- Keep $PHPIZE_DEPS in the same RUN line.
When it is red 4
Authentication failed. (MongoDB)
Cause. Wrong password or missing authSource=admin.
Fix, step by step
- Use the same MONGO_PASSWORD in both services.
- Add ?authSource=admin to MONGO_URL; if the volume was made with an old password, run down -v.
When it is red 5
SQLSTATE[22032]: Invalid JSON text
Cause. specs was not valid JSON when inserted into MySQL.
Fix, step by step
- Send specs as an object.
- json_encode in MysqlStore makes valid JSON from a PHP array.
Practice tasks
Easy Task 1: Drivers
Why: Image.
What to do: Build shop-php:1.0 and list both extensions.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the php-db folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker build -t shop-php:1.0 .
docker run --rm shop-php:1.0 php -mEasy Task 2: Lint
Why: Syntax.
What to do: Run php -l on every file inside the image.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the php-db folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker run --rm -v "$PWD":/app -w /app shop-php:1.0 sh -c 'for f in src/*.php public/*.php; do php -l $f; done'Easy Task 3: MySQL
Why: Run.
What to do: Start the MySQL stack and add two products.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the php-db folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -f compose.mysql.yaml -p shop-mysql up -d --waitMedium Task 4: JSON query
Why: MySQL.
What to do: List products with 8 GB RAM using specs->>'$.ram_gb'.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the php-db folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -p shop-mysql exec db mysql -ushop -pchange-me-app shop -e "SELECT name FROM products WHERE specs->>'$.ram_gb' = 8"Medium Task 5: Mongo
Why: Run.
What to do: Start the Mongo stack with the same image.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the php-db folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -f compose.mongo.yaml -p shop-mongo up -d --waitMedium Task 6: Nested query
Why: MongoDB.
What to do: Find products with specs.ram_gb 8 in mongosh.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the php-db folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -p shop-mongo exec mongo mongosh -u root -p change-me-mongo --quiet --eval 'db.getSiblingDB("shop").products.find({"specs.ram_gb":8})'Medium Task 7: Backup
Why: Both.
What to do: mysqldump the MySQL stack and mongodump the Mongo stack.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the php-db folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -p shop-mysql exec db sh -c 'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" shop' > shop.sqlHard Task 8: Filter
Why: Feature.
What to do: Add max_price to both stores.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the php-db folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
curl -s 'localhost:8000/products?max_price=20000'Hard Task 9: Index
Why: Speed.
What to do: Create an index on price in both and check the plans.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the php-db folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
db.getSiblingDB('shop').products.createIndex({price: 1})
CREATE INDEX idx_price ON products (price);Easy Task 10: Clean
Why: Habit.
What to do: Remove both stacks.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the php-db folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -f compose.mongo.yaml -p shop-mongo down -v
docker compose -f compose.mysql.yaml -p shop-mysql down -vHomework: try at home
Add a GET /products?max_price=20000 filter to both stores (WHERE price <= ? in MySQL, {price: {$lte: ...}} in MongoDB), add an index in each database, and compare the query plans with EXPLAIN and explain().
Recap
- One PHP image carries pdo_mysql and the mongodb extension.
- Composer runs in its own build stage.
- An interface hides which database is used.
- DB_KIND picks the database at start.
- MySQL uses a JSON column; MongoDB stores nested documents.
- Each stack gets its own project name and volume.
FAQ
Q. Should a real app support two databases?
Usually no. This project is for learning and comparing; pick one for production.
Q. Can I use Laravel instead?
Yes. Laravel has MySQL built in and the mongodb/laravel-mongodb package for MongoDB.
Q. Why the PHP built-in server?
It keeps the project small. For real traffic use PHP-FPM with nginx, as in Project 14.
Q. Where do I find the right mongodb extension version?
On the PECL mongodb page; pin it like mongodb-1.20.0.
Q. Can MongoDB do transactions?
Yes, on a replica set. A single mongo:7 container can run as a one-node replica set for tests.
Q. Is Amazon DocumentDB the same as MongoDB?
It is MongoDB compatible; most PHP code works. See Project 58.2 for AWS databases.
Interview questions
-
How do you install a PECL extension in a PHP Docker image?
Model answer: Install $PHPIZE_DEPS, pecl install name-version, docker-php-ext-enable, then delete build tools in the same RUN.
-
SQL or NoSQL for a product catalogue?
Model answer: SQL for strict relations and transactions; a document store when each item has different fields. MySQL JSON columns are a middle way.
-
How do you keep the PHP image small?
Model answer: Use alpine, multi-stage for Composer, remove build dependencies, and copy only vendor, src and public.
-
How do you switch databases without a new image?
Model answer: Read the database type and connection string from environment variables.
-
How do you back up MongoDB in Docker?
Model answer: Run mongodump in the container with --archive and redirect it to a host file or S3.
Quick quiz
Q1. Why build both drivers into one image?
So one image runs with either database, chosen by an environment variable.
Q2. How is the mongodb extension installed?
pecl install mongodb, then docker-php-ext-enable mongodb, in the Dockerfile.
Q3. Why use a Composer build stage?
Composer is needed only to download vendor/; the final image stays small.
Q4. Why convert ids to strings?
MySQL ids are numbers and MongoDB ids are ObjectIds; strings keep the API the same.
Q5. Which database fits products with different fields?
MongoDB fits naturally; MySQL can use a JSON column.
Samajla ka?
Revision card
Interview card
Comparison card