How to Build a Simple REST API in PHP and MySQL Using PDO (CRUD with JSON)
Sooner or later every web project needs to talk to something other than a browser. A mobile app wants to load products, a JavaScript front end wants to save a form without reloading the page, or a classmate's system needs to read your data for a capstone integration. The usual answer is a REST API: a set of URLs that accept and return JSON.
In this tutorial, we will build a small but complete REST API in plain PHP and MySQL using PDO, with no framework. It will support all four CRUD operations (Create, Read, Update, Delete), validate input, use prepared statements to block SQL injection, and return proper HTTP status codes. Everything here was tested with curl, and the outputs you see below are the real responses.
What We Are Building
Our API manages a simple products table. Here are the endpoints:
- GET
api.php– list all products - GET
api.php?id=1– get one product - POST
api.php– create a product (JSON body) - PUT
api.php?id=1– update a product (JSON body) - DELETE
api.php?id=1– delete a product
Requirements
- PHP 8.0 or newer with the pdo_mysql extension (XAMPP, WAMP, Laragon or any Linux server works)
- MySQL or MariaDB
- A tool for sending requests:
curl, Postman, or Thunder Client in VS Code
Step 1: Create the Database and Table
Create a database named rest_api_demo (in phpMyAdmin or the MySQL console), then run this SQL to create the table with two sample rows:
- CREATE TABLE IF NOT EXISTS products (
- id INT AUTO_INCREMENT PRIMARY KEY,
- name VARCHAR(100) NOT NULL,
- price DECIMAL(10,2) NOT NULL,
- stock INT NOT NULL DEFAULT 0,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- );
- INSERT INTO products (name, price, stock) VALUES
- ('USB-C Charger', 499.00, 25),
- ('Wireless Mouse', 350.50, 40);
Step 2: Connect to MySQL with PDO
Create a file named db.php. It opens one PDO connection that the API will reuse. Three options are worth pointing out: ERRMODE_EXCEPTION makes database errors throw exceptions instead of failing silently, FETCH_ASSOC returns rows as clean associative arrays that convert nicely to JSON, and turning off EMULATE_PREPARES makes MySQL handle the prepared statements natively (it also returns integers as real numbers in the JSON).
- <?php
- // db.php - creates one PDO connection for the whole API
- $host = 'localhost';
- $db = 'rest_api_demo';
- $user = 'demo';
- $pass = 'demo123';
- try {
- $pdo = new PDO(
- "mysql:host=$host;dbname=$db;charset=utf8mb4",
- $user,
- $pass,
- [
- PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
- PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
- PDO::ATTR_EMULATE_PREPARES => false,
- ]
- );
- } catch (PDOException $e) {
- http_response_code(500);
- exit;
- }
Notice that if the connection fails, we still answer in JSON with a 500 status. Clients of an API should never receive an HTML error page, and you should never echo the real database error to the public, because it can reveal your credentials or table names.
Step 3: Write the API
Now create api.php in the same folder. This single file reads the HTTP method, decides what the client wants, and responds with JSON.
- <?php
- // api.php - a small JSON REST API for the products table
- require 'db.php';
- function respond(int $status, $data): void
- {
- http_response_code($status);
- exit;
- }
- {
- respond(400, ['error' => 'Request body must be valid JSON']);
- }
- return $data;
- }
- {
- $errors = [];
- $errors['name'] = 'Name is required';
- }
- $errors['price'] = 'Price must be a positive number';
- }
- $errors['stock'] = 'Stock must be a whole number';
- }
- return $errors;
- }
- $method = $_SERVER['REQUEST_METHOD'];
- if ($id === false) {
- respond(400, ['error' => 'Invalid product id']);
- }
- switch ($method) {
- case 'GET':
- if ($id) {
- $stmt = $pdo->prepare('SELECT * FROM products WHERE id = ?');
- $stmt->execute([$id]);
- $product = $stmt->fetch();
- $product ? respond(200, $product) : respond(404, ['error' => 'Product not found']);
- }
- $rows = $pdo->query('SELECT * FROM products ORDER BY id')->fetchAll();
- respond(200, $rows);
- case 'POST':
- $data = readJsonBody();
- $errors = validateProduct($data);
- if ($errors) {
- respond(422, ['errors' => $errors]);
- }
- $stmt = $pdo->prepare('INSERT INTO products (name, price, stock) VALUES (?, ?, ?)');
- respond(201, ['message' => 'Product created', 'id' => (int) $pdo->lastInsertId()]);
- case 'PUT':
- if (!$id) {
- respond(400, ['error' => 'Product id is required']);
- }
- $data = readJsonBody();
- $errors = validateProduct($data);
- if ($errors) {
- respond(422, ['errors' => $errors]);
- }
- $stmt = $pdo->prepare('UPDATE products SET name = ?, price = ?, stock = ? WHERE id = ?');
- if ($stmt->rowCount() === 0) {
- $check = $pdo->prepare('SELECT id FROM products WHERE id = ?');
- $check->execute([$id]);
- if (!$check->fetch()) {
- respond(404, ['error' => 'Product not found']);
- }
- }
- respond(200, ['message' => 'Product updated']);
- case 'DELETE':
- if (!$id) {
- respond(400, ['error' => 'Product id is required']);
- }
- $stmt = $pdo->prepare('DELETE FROM products WHERE id = ?');
- $stmt->execute([$id]);
- $stmt->rowCount()
- ? respond(200, ['message' => 'Product deleted'])
- : respond(404, ['error' => 'Product not found']);
- default:
- respond(405, ['error' => 'Method not allowed']);
- }
How the code works
- respond() is a small helper that sets the HTTP status code, prints the JSON and stops the script. Using one helper keeps every response consistent.
- readJsonBody() reads the raw request body from
php://input. Unlike normal HTML forms, API clients send JSON, so$_POSTwould be empty. If the JSON is broken, the client gets a 400 Bad Request. - validateProduct() collects every error at once and returns them together, so a front end can show all messages in one go. Invalid data gets 422 Unprocessable Entity.
- The id from the URL is checked with
FILTER_VALIDATE_INT, and every query that uses user input is a prepared statement with?placeholders. This is what protects the API from SQL injection. - The switch on
$_SERVER['REQUEST_METHOD']is our tiny router. Any method we do not support gets 405 Method Not Allowed plus anAllowheader. - For PUT, MySQL reports zero affected rows when you save the same values again, so we double-check whether the product really exists before answering 404.
Step 4: Run and Test the API
If you use XAMPP, put the folder inside htdocs and use http://localhost/your-folder/api.php. You can also use PHP's built-in server from inside the project folder:
- php -S localhost:8000
Get all products
- curl http://localhost:8000/api.php
[
{
"id": 1,
"name": "USB-C Charger",
"price": "499.00",
"stock": 25,
"created_at": "2026-10-31 09:13:41"
},
{
"id": 2,
"name": "Wireless Mouse",
"price": "350.50",
"stock": 40,
"created_at": "2026-10-31 09:13:41"
}
]
Create a product
- curl -i -X POST http://localhost:8000/api.php \
- -H "Content-Type: application/json" \
- -d '{"name":"Mechanical Keyboard","price":1899.99,"stock":10}'
HTTP/1.1 201 Created
{
"message": "Product created",
"id": 3
}
Read, update and delete one product
- curl "http://localhost:8000/api.php?id=3"
- curl -X PUT "http://localhost:8000/api.php?id=3" \
- -H "Content-Type: application/json" \
- -d '{"name":"Mechanical Keyboard","price":1750,"stock":8}'
- curl -X DELETE "http://localhost:8000/api.php?id=3"
The three responses, in order:
{
"id": 3,
"name": "Mechanical Keyboard",
"price": "1899.99",
"stock": 10,
"created_at": "2026-10-31 09:13:42"
}
{
"message": "Product updated"
}
{
"message": "Product deleted"
}
Error responses
A good API is just as clear when something goes wrong. Sending an empty name and a non-numeric price returns status 422 with this body:
{
"errors": {
"name": "Name is required",
"price": "Price must be a positive number"
}
}
Asking for the deleted product again, using an unsupported method like PATCH, or passing a bad id gives:
HTTP/1.1 404 Not Found
{
"error": "Product not found"
}
HTTP/1.1 405 Method Not Allowed
{
"error": "Method not allowed"
}
HTTP/1.1 400 Bad Request
{
"error": "Invalid product id"
}
Calling the API from JavaScript
Because the API speaks JSON, any front end can use it. For example, from a page on the same site:
- const response = await fetch('api.php', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name: 'Webcam', price: 1200, stock: 5 })
- });
- const result = await response.json();
- console.log(response.status, result);
Tips Before You Use This in a Real Project
- Add authentication. Right now anyone who knows the URL can delete products. Protect write operations with an API key, a session login or JWT tokens.
- Enable CORS only if needed. If your front end runs on a different domain, add
Access-Control-Allow-Originheaders for that domain only, not*. - Use clean URLs. With an
.htaccessrewrite rule you can turnapi.php?id=3into/api/products/3. - Add pagination with
LIMITandOFFSETonce the table grows.
Conclusion
You now have a working PHP and MySQL REST API that handles GET, POST, PUT and DELETE, validates input, uses PDO prepared statements, and answers with meaningful status codes. This same pattern is the foundation of the back end for many student projects, mobile apps and single-page applications. You can download the complete source code below and extend it with your own tables. Happy coding!
More Tutorials for PHP