Ravindra BagaleCourses & study guides मराठी

Chapter 57: Docker Projects Part 1: Websites, Databases, LAMP, LEMP, WordPress, 3-Tier and Every Language with MySQL and MongoDB

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.
Build a PHP image with both drivers. Write one API with two stores. Run with MySQL. Run with MongoDB. Compare queries. Back up both. Clean up.
One PHP image, two databases chosen by DB_KIND

How: step by step

  1. Build a PHP image with both drivers. pdo_mysql, pecl mongodb, Composer.
  2. Write one API with two stores. ProductStore interface.
  3. Run with MySQL. compose.mysql.yaml.
  4. Run with MongoDB. compose.mongo.yaml, same image.
  5. Compare queries. JSON column vs documents.
  6. Back up both. mysqldump and mongodump.
  7. Clean up. down -v.

Architecture

Client: curl /products shop-php:1.0 (pdo_mysql + mongodb extension) ProductStore interface MysqlStore -> MySQL 8.4 MongoStore -> MongoDB 7 One named volume per database
PHP with MySQL and MongoDB 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)

  1. Make folders

    mkdir -p php-db/src php-db/public && cd php-db
    

    What it does: Code in src/, the entry file in public/.

    (no output)
    
  2. Make composer.lock

    docker run --rm -v "$PWD":/app -w /app composer:2 composer update --ignore-platform-req=ext-mongodb
    

    What it does: Composer runs in a container; no PHP on the laptop.

    Writing lock file
    
  3. 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
    
  4. 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
    
  5. Start MySQL stack

    docker compose -f compose.mysql.yaml -p shop-mysql up -d --wait
    

    What it does: API and MySQL.

    Container shop-mysql-api-1 Healthy
    
  6. 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"}
    
  7. 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
    
  8. Switch to Mongo

    docker compose -f compose.mysql.yaml -p shop-mysql down
    docker compose -f compose.mongo.yaml -p shop-mongo up -d --wait
    

    What it does: Same image, other database.

    Container shop-mongo-api-1 Healthy
    
  9. 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"}
    
  10. Back up Mongo

    docker compose -p shop-mongo exec mongo mongodump -u root -p change-me-mongo --authenticationDatabase admin --db shop --archive > shop.archive
    

    What it does: A mongodump archive on the host.

    ls -l shop.archive
    
  11. See logs

    docker compose -p shop-mongo logs api --tail 3
    

    What it does: PHP built-in server logs each request.

    [200]: GET /products
    
  12. 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.0
    

    What 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

  1. Check COPY --from=vendor /app/vendor ./vendor in the Dockerfile.
  2. 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

  1. Add --ignore-platform-req=ext-mongodb in the composer stage.
  2. 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

  1. Add openssl-dev to the apk add line.
  2. 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

  1. Use the same MONGO_PASSWORD in both services.
  2. 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

  1. Send specs as an object.
  2. 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.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the php-db folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker build -t shop-php:1.0 .
docker run --rm shop-php:1.0 php -m

Easy Task 2: Lint

Why: Syntax.

What to do: Run php -l on every file inside the image.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the php-db folder with the files from this lesson.
  3. 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.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the php-db folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose -f compose.mysql.yaml -p shop-mysql up -d --wait

Medium Task 4: JSON query

Why: MySQL.

What to do: List products with 8 GB RAM using specs->>'$.ram_gb'.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the php-db folder with the files from this lesson.
  3. 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.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the php-db folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose -f compose.mongo.yaml -p shop-mongo up -d --wait

Medium Task 6: Nested query

Why: MongoDB.

What to do: Find products with specs.ram_gb 8 in mongosh.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the php-db folder with the files from this lesson.
  3. 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.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the php-db folder with the files from this lesson.
  3. 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.sql

Hard Task 8: Filter

Why: Feature.

What to do: Add max_price to both stores.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the php-db folder with the files from this lesson.
  3. 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.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the php-db folder with the files from this lesson.
  3. 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.

How:
  1. Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
  2. Create the php-db folder with the files from this lesson.
  3. 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 -v

Homework: 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

  1. 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.

  2. 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.

  3. 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.

  4. How do you switch databases without a new image?

    Model answer: Read the database type and connection string from environment variables.

  5. 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?

Ravi Sir's Cards for this lesson

मराठी cards