Build a Bilingual Content Library
Build a PHP admin panel to manage Malayalam/English content with MySQL.
Introduction
30 Second Summary
Stories and teachings passed down through generations deserve to live in the language they were spoken in. When you want to share them with a wider audience, you also need a translation side by side.
In this project, you will build a secure admin panel that lets you store bilingual content (Malayalam and English) in a database, tag entries with keywords, and manage everything through your browser.
What You'll Build
A PHP-based admin panel backed by a MySQL database for managing a bilingual content library of stories, anecdotes, and teachings.
By the end of this project, you'll have:
- A MySQL database with normalized tables for content, keywords, and users.
- A login system with password-hashed authentication and role-based sessions.
- A full admin dashboard to add, edit, delete, and filter bilingual content with keyword tagging.
- Secret Mission: Add a two-column preview showing how content will appear in the user-facing Malayalam/English layout.
Are there any prerequisites?
You need basic HTML knowledge and access to a web server running PHP 8.3 with MySQL and FTP upload. This is Project 1 of 2. Project 2 will add the user-facing reading interface with search.
Set Up the Database and Project Structure
Before you can build an admin panel, you need a place to store the bilingual content. A MySQL database will hold the Malayalam text, English translations, keywords, and user accounts that power the entire library.
In this step, you will create the database with its tables, set up the local project folder structure, and write the configuration files that connect your PHP code to the database.
In this step, get ready to:
- Create the MySQL database with tables for content, keywords, and users.
- Create the project folder structure with all necessary files.
- Write the database connection config and seed the initial admin user.
Create the MySQL database and tables
Your bilingual library needs four tables: one for the content entries, one for keywords, a junction table that links content to keywords, and one for user accounts.
- Open your MySQL interface. This could be phpMyAdmin (via your hosting panel), a MySQL command line client, or another database tool your hosting provides.
- Navigate to the SQL query editor (in phpMyAdmin, click the SQL tab at the top).
- Create the database and all four tables by running this SQL:
-- Create the database with full Unicode support
CREATE DATABASE amma_library
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE amma_library;
-- Table for bilingual content entries
CREATE TABLE content (
id INT AUTO_INCREMENT PRIMARY KEY,
title_ml TEXT,
title_en TEXT,
body_ml LONGTEXT,
body_en LONGTEXT,
category VARCHAR(50),
created_at DATETIME,
updated_at DATETIME
);
-- Table for reusable keywords
CREATE TABLE keywords (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100)
);
-- Junction table linking content to keywords (many-to-many)
CREATE TABLE content_keywords (
content_id INT,
keyword_id INT
);
-- Table for admin and reader user accounts
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100),
password_hash VARCHAR(255),
role VARCHAR(20)
);
What does this SQL do?
This creates a database called amma_library with the utf8mb4 character set, which supports the full range of Unicode characters including Malayalam script.
- content stores the Malayalam and English text for each entry, along with a category (Story, Anecdote, or Teaching).
- keywords holds reusable tags like "compassion" or "devotion" that you can assign to multiple entries.
- content_keywords is a junction table that creates a many-to-many relationship between content and keywords.
- users stores login credentials with hashed passwords and a role field for access control.
- After running the SQL, verify the tables were created. In phpMyAdmin, click the amma_library database in the left sidebar. You should see all four tables listed: content, keywords, content_keywords, and users.
Don't see the tables?
If you get an "Access denied" error, your MySQL user may not have permission to create databases. Contact your hosting provider or use the database creation tool in your hosting panel (like cPanel) to create the amma_library database first, then run only the CREATE TABLE statements (skip the CREATE DATABASE and USE lines).
If you see "Table already exists", the SQL ran partially on a previous attempt. Drop the database with DROP DATABASE amma_library; and re-run the full script.
help me troubleshoot my MySQL database creation
Create the project folder structure on your computer
Your project needs a clean folder structure with separate files for each piece of functionality. You will create the files locally and upload them to your server via your FTP client.
- Create a new folder on your computer called amma-library. You can place it on your Desktop or in any location you prefer.
- Inside amma-library/, create these files: config.php, login.php, logout.php, setup.php, and style.css.
- Inside amma-library/, create a subfolder called admin.
- Inside the admin/ subfolder, create these files: index.php, add.php, edit.php, and delete.php.
Your final folder structure should look like this:
amma-library/
├── config.php
├── login.php
├── logout.php
├── setup.php
├── style.css
└── admin/
├── index.php
├── add.php
├── edit.php
└── delete.php
Why so many empty files?
Creating the file structure upfront gives you a clear map of the project before you start coding. The files login.php, logout.php, style.css, and the admin/ files will stay empty for now. You will fill them in during the next steps.
- Verify your folder structure by opening the amma-library folder in Finder (macOS) or File Explorer (Windows). You should see five files and one admin folder. Open the admin folder to confirm it contains four files.
Write the database connection and seed users
PHP needs a connection file that every page can reuse to talk to the database. PDO (PHP Data Objects) is the recommended way to connect to MySQL because it supports prepared statements that protect against SQL injection.
- Open config.php in your text editor and add the following database connection code:
<?php
$host = '127.0.0.1';
$db = 'amma_library';
$user = 'your-db-username-here';
$pass = 'your-db-password-here';
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $user, $pass, $options);
} catch (\PDOException $e) {
die("Database connection failed: " . $e->getMessage());
}
?>
What does this code do?
- $dsn builds the Data Source Name string that tells PDO where the database lives and which charset to use.
- ERRMODE_EXCEPTION makes PDO throw exceptions on errors instead of failing silently, which makes debugging much easier.
- FETCH_ASSOC returns query results as associative arrays (key-value pairs) instead of numbered indexes.
- ATTR_EMULATE_PREPARES => false forces real prepared statements at the MySQL level, giving you genuine protection against SQL injection.
- Replace your-db-username-here and your-db-password-here with the MySQL username and password your hosting provides.
- If your database is on a different host (not localhost), replace 127.0.0.1 with the host address your hosting panel shows.
- Save config.php.
Not sure what your database credentials are?
Check your hosting panel (cPanel, Plesk, or similar) under the MySQL Databases section. You will find the username, host, and can reset the password there. Some hosts require you to add the user to the database manually after creating both.
help me find my MySQL database credentials
Now you need a script to create the initial admin and reader accounts with secure hashed passwords.
- Open setup.php in your text editor and add the following user-seeding code:
<?php
require 'config.php';
$adminPassword = password_hash('admin123', PASSWORD_DEFAULT);
$readerPassword = password_hash('reader123', PASSWORD_DEFAULT);
$stmt = $pdo->prepare("INSERT INTO users (username, password_hash, role) VALUES (:username, :password_hash, :role)");
$stmt->execute([
'username' => 'admin',
'password_hash' => $adminPassword,
'role' => 'admin'
]);
$stmt->execute([
'username' => 'reader',
'password_hash' => $readerPassword,
'role' => 'reader'
]);
echo "Users created successfully. Delete this file now.";
?>
What does this code do?
- password_hash() converts the plain-text passwords into secure bcrypt hashes. Even if someone accesses your database, they cannot reverse the hashes back into passwords.
- PASSWORD_DEFAULT tells PHP to use its recommended algorithm (currently bcrypt with a cost of 10 in PHP 8.3). If PHP upgrades the algorithm in future versions, existing hashes still verify correctly.
- The prepared statement with named placeholders (:username, :password_hash, :role) prevents SQL injection by separating the query structure from the data.
- Save setup.php.
- Upload the entire amma-library folder to your web server using your FTP client. Place it in your server's web-accessible directory (often called public_html or www).
- Open your browser and navigate to setup.php on your server (e.g. http://yourdomain.com/amma-library/setup.php).
- You should see the message: "Users created successfully. Delete this file now."
- Delete or rename setup.php on your server immediately. This file contains plain-text passwords and should never remain accessible.
Seeing a database connection error?
If you see "Database connection failed" when visiting setup.php, the credentials in config.php are incorrect. Double-check the username, password, and host values against what your hosting panel shows.
If you see a blank white page, PHP errors may be hidden. Add ini_set('display_errors', 1); at the top of setup.php (after the <?php tag) to reveal the error message.
help me fix my PHP database connection error
Why delete setup.php?
This file inserts users with known passwords. If left on the server, anyone who guesses the URL could re-run it and create duplicate accounts. Always remove seed scripts after they have done their job.
✔️ Awesome, I've got everything!
Great. Make sure you have saved config.php and confirmed that setup.php ran successfully before moving on.
ⓧ I'd like to double check the full code
Here is the complete config.php:
<?php
$host = '127.0.0.1';
$db = 'amma_library';
$user = 'your-db-username-here';
$pass = 'your-db-password-here';
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $user, $pass, $options);
} catch (\PDOException $e) {
die("Database connection failed: " . $e->getMessage());
}
?>
Here is the complete setup.php:
<?php
require 'config.php';
$adminPassword = password_hash('admin123', PASSWORD_DEFAULT);
$readerPassword = password_hash('reader123', PASSWORD_DEFAULT);
$stmt = $pdo->prepare("INSERT INTO users (username, password_hash, role) VALUES (:username, :password_hash, :role)");
$stmt->execute([
'username' => 'admin',
'password_hash' => $adminPassword,
'role' => 'admin'
]);
$stmt->execute([
'username' => 'reader',
'password_hash' => $readerPassword,
'role' => 'reader'
]);
echo "Users created successfully. Delete this file now.";
?>
Your database is live, your project structure is in place, and you have an admin account ready to go. Next up, you will build the login system that protects the admin panel.
Build the Login System
Your database has users ready and waiting. Now you need a gate that checks credentials before anyone can manage content.
In this step, you'll build a session-based login system using PHP's built-in password_verify() function. Sessions let the server remember who is logged in across page loads without re-checking the password every time.
In this step, get ready to:
- Build a login page that verifies credentials and starts a session.
- Write the stylesheet for the entire application.
- Create a logout script and test the authentication flow.
Create the login page
The login page needs to do two things: show an HTML form, and process the submitted credentials on the server side. PHP makes this easy by handling both in a single file.
- Open login.php in your text editor and add the PHP authentication logic by pasting this code:
<?php
session_start();
// If admin is already logged in, skip the login page
if (isset($_SESSION['user_role'])) {
if ($_SESSION['user_role'] === 'admin') {
header('Location: admin/index.php');
exit;
}
}
$error = '';
// Process form submission
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require 'config.php';
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
// Look up the user by username
$stmt = $pdo->prepare("SELECT id, username, password_hash, role FROM users WHERE username = :username");
$stmt->execute(['username' => $username]);
$user = $stmt->fetch();
// Verify the password against the stored hash
if ($user && password_verify($password, $user['password_hash'])) {
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['user_role'] = $user['role'];
if ($user['role'] === 'admin') {
header('Location: admin/index.php');
exit;
} else {
$error = 'Reader access coming soon in Project 2.';
}
} else {
$error = 'Invalid username or password.';
}
}
?>
What does this code do?
session_start() initializes PHP's session system so you can store data (like who's logged in) across page loads. It must appear before any HTML output.
The first if block checks whether an admin is already logged in. If so, it redirects them straight to the dashboard instead of showing the login form again.
password_verify() compares the plain-text password the user typed against the hashed version stored in your database. It never stores or reveals the original password.
- Directly below the closing ?> tag, add the HTML login form:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Amma Library</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="login-container">
<h1>Amma Library</h1>
<h2>Login</h2>
<?php if ($error): ?>
<p class="error"><?php echo htmlspecialchars($error); ?></p>
<?php endif; ?>
<form method="POST" action="login.php">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>
What does this HTML do?
The form posts back to login.php itself. When submitted, the PHP block at the top processes the credentials.
htmlspecialchars() wraps the error message to prevent cross-site scripting (XSS) attacks. Always escape user-facing output.
- Save login.php.
Why is session_start() the very first line?
PHP sessions work by sending a cookie header to the browser. HTTP headers must be sent before any page content. If even a single space or blank line appears before session_start(), PHP throws a "headers already sent" warning and the session fails to initialize.
✔️ Awesome, I've got everything!
Great. Make sure you saved the file before moving on.
ⓧ I'd like to double check the full code
<?php
session_start();
if (isset($_SESSION['user_role'])) {
if ($_SESSION['user_role'] === 'admin') {
header('Location: admin/index.php');
exit;
}
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require 'config.php';
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$stmt = $pdo->prepare("SELECT id, username, password_hash, role FROM users WHERE username = :username");
$stmt->execute(['username' => $username]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['user_role'] = $user['role'];
if ($user['role'] === 'admin') {
header('Location: admin/index.php');
exit;
} else {
$error = 'Reader access coming soon in Project 2.';
}
} else {
$error = 'Invalid username or password.';
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Amma Library</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="login-container">
<h1>Amma Library</h1>
<h2>Login</h2>
<?php if ($error): ?>
<p class="error"><?php echo htmlspecialchars($error); ?></p>
<?php endif; ?>
<form method="POST" action="login.php">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>
Write the application stylesheet
Your login page references style.css but the file is still empty. You'll add all the styles for the entire application now so every page looks polished as you build it.
- Open style.css in your text editor and paste the full stylesheet:
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+Malayalam:wght@400;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.login-container {
max-width: 400px;
margin: 100px auto;
padding: 40px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.login-container h1 {
text-align: center;
margin-bottom: 8px;
color: #2c3e50;
}
.login-container h2 {
text-align: center;
margin-bottom: 24px;
color: #666;
font-weight: normal;
}
label {
display: block;
margin-bottom: 4px;
font-weight: 600;
color: #555;
}
input[type="text"],
input[type="password"],
select,
textarea {
width: 100%;
padding: 10px 12px;
margin-bottom: 16px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
}
textarea {
min-height: 150px;
resize: vertical;
}
.malayalam-input {
font-family: 'Noto Sans Malayalam', sans-serif;
font-size: 18px;
}
button {
width: 100%;
padding: 12px;
background: #3498db;
color: #fff;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
}
button:hover {
background: #2980b9;
}
.error {
background: #fee;
color: #c0392b;
padding: 10px;
border-radius: 4px;
margin-bottom: 16px;
}
.success {
background: #efd;
color: #27ae60;
padding: 10px;
border-radius: 4px;
margin-bottom: 16px;
}
.admin-container {
max-width: 1100px;
margin: 0 auto;
padding: 20px;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 2px solid #3498db;
margin-bottom: 24px;
}
.admin-header h1 {
color: #2c3e50;
}
.admin-nav a {
margin-left: 16px;
text-decoration: none;
color: #3498db;
font-weight: 600;
}
.admin-nav a:hover {
color: #2980b9;
}
.filter-tabs {
margin-bottom: 20px;
}
.filter-tabs a {
display: inline-block;
padding: 8px 16px;
margin-right: 8px;
text-decoration: none;
background: #ecf0f1;
color: #555;
border-radius: 4px;
}
.filter-tabs a.active {
background: #3498db;
color: #fff;
}
.content-table {
width: 100%;
border-collapse: collapse;
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.content-table th,
.content-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #eee;
}
.content-table th {
background: #2c3e50;
color: #fff;
font-weight: 600;
}
.content-table tr:hover {
background: #f8f9fa;
}
.content-table .malayalam {
font-family: 'Noto Sans Malayalam', sans-serif;
}
.actions a {
margin-right: 8px;
text-decoration: none;
padding: 4px 10px;
border-radius: 3px;
font-size: 14px;
}
.actions .edit-link {
background: #f39c12;
color: #fff;
}
.actions .delete-link {
background: #e74c3c;
color: #fff;
}
.actions .preview-link {
background: #9b59b6;
color: #fff;
}
.keyword-tag {
display: inline-block;
background: #ecf0f1;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
margin: 2px;
color: #555;
}
.form-container {
max-width: 800px;
background: #fff;
padding: 32px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.form-container h2 {
margin-bottom: 24px;
color: #2c3e50;
}
.btn-add {
display: inline-block;
padding: 10px 20px;
background: #27ae60;
color: #fff;
text-decoration: none;
border-radius: 4px;
font-weight: 600;
margin-bottom: 20px;
}
.btn-add:hover {
background: #219a52;
}
.confirm-box {
background: #fff;
padding: 32px;
border-radius: 8px;
max-width: 500px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.confirm-box h2 {
color: #e74c3c;
margin-bottom: 16px;
}
.confirm-box .btn-cancel {
display: inline-block;
padding: 10px 20px;
background: #95a5a6;
color: #fff;
text-decoration: none;
border-radius: 4px;
margin-right: 10px;
}
.confirm-box button {
width: auto;
padding: 10px 20px;
background: #e74c3c;
}
What's in this stylesheet?
The first line imports Noto Sans Malayalam from Google Fonts. This ensures Malayalam text renders with proper character shaping throughout the app.
The .login-container styles center the login form with a card-like appearance. The remaining classes (.admin-container, .content-table, .form-container) style the admin pages you'll build in later steps.
- Save style.css.
- Upload both login.php and style.css to your server via FTP.
- Visit login.php in your browser. You should see a centered white card with "Amma Library" as the heading, username and password fields, and a blue Login button.
Seeing unstyled plain text?
Make sure style.css is uploaded to the same directory as login.php. The <link> tag uses a relative path, so both files must be in the same folder.
help me figure out why my login page styles are not loading
Create the logout script
Logout is simple. Destroy the session data and send the user back to the login page.
- Open logout.php in your text editor and paste this code:
<?php
session_start();
session_destroy();
header('Location: login.php');
exit;
?>
What does this code do?
session_start() is required even for destroying a session. It resumes the existing session so session_destroy() can wipe all stored data. Then header('Location: login.php') sends the browser back to the login page.
- Save logout.php and upload it to your server via FTP.
Add the authentication guard and test
Every admin page needs to reject visitors who are not logged in as an admin. You'll add a guard block at the top of each admin file that checks the session and redirects unauthorized visitors.
- Open admin/index.php in your text editor and add this authentication guard:
<?php
session_start();
// Redirect to login if the visitor is not an admin
if (!isset($_SESSION['user_role']) || $_SESSION['user_role'] !== 'admin') {
header('Location: ../login.php');
exit;
}
?>
<p>Admin Dashboard - coming soon.</p>
What does the guard do?
This three-line check runs before any page content. If $_SESSION['user_role'] is missing or not 'admin', the visitor is immediately redirected to the login page. The exit prevents any code below from executing.
You'll add this same guard to the top of every file in the admin/ folder. In the next step, you'll replace the placeholder <p> tag with the full dashboard.
- Save admin/index.php and upload it to your server via FTP.
Now test the full authentication flow.
- Open a new browser tab (or use incognito/private mode) and visit admin/index.php directly without logging in. You should be redirected to login.php.
- Enter admin as the username and admin123 as the password, then click Login.
- You should be redirected to admin/index.php and see the text "Admin Dashboard - coming soon." on the page.
- Go back to login.php and try logging in with reader / reader123. You should see the message "Reader access coming soon in Project 2." on the login page.
Not getting redirected?
Make sure there are no blank lines or spaces before <?php in both login.php and admin/index.php. PHP cannot send redirect headers if any output (even whitespace) has already been sent.
Also confirm that config.php has the correct database credentials. If the credentials are wrong, the login will silently fail because the query returns no user.
help me debug why my PHP login redirect is not working
Your authentication system is locked down. Next up, you'll build the admin dashboard that lists all your bilingual content entries.
Build the Admin Dashboard
Your login system is working. When you sign in as admin, the browser redirects to admin/index.php. Right now that page is blank.
In this step, you will build the admin dashboard that lists all content entries from the database. The dashboard gives you a command center to view, filter, and navigate to edit or delete any piece of bilingual content.
In this step, get ready to:
- Query content entries from the database with keyword lookups.
- Display entries in an HTML table with action links.
- Add category filter tabs to browse content by type.
Write the PHP logic to query content entries
The dashboard needs to fetch all content from the database, support optional category filtering, and prepare a statement for looking up keywords. This PHP block goes at the very top of the file, before any HTML.
- Open admin/index.php in your text editor.
- Replace any existing content with the following PHP logic:
<?php
session_start();
if (!isset($_SESSION['user_role']) || $_SESSION['user_role'] !== 'admin') {
header('Location: ../login.php');
exit;
}
require '../config.php';
// Read optional category filter from URL (defaults to 'all')
$category_filter = $_GET['category'] ?? 'all';
// Build the SQL query with an optional WHERE clause
$sql = "SELECT c.id, c.title_ml, c.title_en, c.category, c.created_at FROM content c";
$params = [];
if ($category_filter !== 'all') {
$sql .= " WHERE c.category = :category";
$params['category'] = $category_filter;
}
$sql .= " ORDER BY c.created_at DESC";
// Execute the content query
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$entries = $stmt->fetchAll();
// Prepare a reusable statement for fetching keywords per entry
$keyword_stmt = $pdo->prepare("SELECT k.name FROM keywords k JOIN content_keywords ck ON k.id = ck.keyword_id WHERE ck.content_id = :content_id");
?>
What does this code do?
The first few lines act as a gate. If the visitor does not have an active admin session, they get redirected back to the login page. This protects the entire dashboard from unauthorized access.
The query uses a conditional WHERE clause. When $category_filter is 'all', no filter is applied and every entry is returned. Otherwise, only entries matching the selected category appear.
The $keyword_stmt is prepared once and reused inside the HTML loop for each entry. This avoids re-preparing the same statement on every iteration.
What is a prepared statement?
A prepared statement separates the SQL structure from the data values. The database compiles the SQL once, and you pass different values each time you execute it. This prevents SQL injection attacks because user input can never be interpreted as SQL commands.
Add the HTML header and navigation
Below the PHP closing tag, you will add the HTML page shell. This includes the page head, the admin header with navigation links, a success message area, and a prominent button for adding new content.
- In admin/index.php, add the following HTML directly below the closing ?> tag:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard - Amma Library</title>
<link rel="stylesheet" href="../style.css">
</head>
<body>
<div class="admin-container">
<!-- Admin header with nav links -->
<div class="admin-header">
<h1>Amma Library Admin</h1>
<nav class="admin-nav">
<span>Hello, <?php echo htmlspecialchars($_SESSION['username']); ?></span>
<a href="add.php">Add Content</a>
<a href="../logout.php">Logout</a>
</nav>
</div>
<!-- Display success messages passed via URL -->
<?php if (isset($_GET['msg'])): ?>
<p class="success"><?php echo htmlspecialchars($_GET['msg']); ?></p>
<?php endif; ?>
<!-- Prominent button to add new content -->
<a href="add.php" class="btn-add">+ Add New Content</a>
What does this code do?
The admin-header div creates a top bar showing the site name, a greeting with the logged-in username, and navigation links. The htmlspecialchars() function prevents XSS attacks by escaping any special characters in the username before outputting it.
The success message block checks for a msg query parameter in the URL. Later steps will redirect here with messages like "Content added successfully" after a successful action.
Add category filter tabs and the content table
The final piece of the dashboard is the category filter and the main content table. The filter tabs let you browse by content type, and the table displays each entry with its keywords and action links.
- Still in admin/index.php, add this code directly below the btn-add link to complete the page:
<!-- Category filter tabs -->
<div class="filter-tabs">
<a href="index.php?category=all" class="<?php echo $category_filter === 'all' ? 'active' : ''; ?>">All</a>
<a href="index.php?category=story" class="<?php echo $category_filter === 'story' ? 'active' : ''; ?>">Stories</a>
<a href="index.php?category=anecdote" class="<?php echo $category_filter === 'anecdote' ? 'active' : ''; ?>">Anecdotes</a>
<a href="index.php?category=teaching" class="<?php echo $category_filter === 'teaching' ? 'active' : ''; ?>">Teachings</a>
</div>
<!-- Content entries table -->
<table class="content-table">
<thead>
<tr>
<th>Title (Malayalam)</th>
<th>Title (English)</th>
<th>Category</th>
<th>Keywords</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($entries)): ?>
<tr><td colspan="5">No content yet. Add your first entry!</td></tr>
<?php endif; ?>
<?php foreach ($entries as $entry): ?>
<?php
// Fetch keywords for this specific entry
$keyword_stmt->execute(['content_id' => $entry['id']]);
$keywords = $keyword_stmt->fetchAll(PDO::FETCH_COLUMN);
?>
<tr>
<td class="malayalam"><?php echo htmlspecialchars($entry['title_ml']); ?></td>
<td><?php echo htmlspecialchars($entry['title_en']); ?></td>
<td><?php echo htmlspecialchars(ucfirst($entry['category'])); ?></td>
<td>
<?php foreach ($keywords as $kw): ?>
<span class="keyword-tag"><?php echo htmlspecialchars($kw); ?></span>
<?php endforeach; ?>
</td>
<td class="actions">
<a href="edit.php?id=<?php echo $entry['id']; ?>" class="edit-link">Edit</a>
<a href="delete.php?id=<?php echo $entry['id']; ?>" class="delete-link">Delete</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</body>
</html>
What does this code do?
The filter tabs are simple links that reload the page with a ?category= query parameter. The PHP logic you wrote earlier reads this parameter and adjusts the SQL query. The active class highlights whichever tab matches the current filter.
Inside the table body, the foreach loop iterates over every content entry. For each row, it executes the $keyword_stmt to fetch that entry's keywords from the junction table. Keywords render as small keyword-tag badges.
The Edit and Delete links pass the entry's id as a query parameter so the next page knows which record to load.
- Save admin/index.php.
- Upload the file to your server via FTP.
- In your browser, log in as admin. You should see the dashboard with the header showing "Hello, admin", the blue "+ Add New Content" button, the category filter tabs (All, Stories, Anecdotes, Teachings), and the table displaying "No content yet. Add your first entry!" since the database is empty.
Seeing a blank page or an error?
- If you get redirected to the login page even after logging in, make sure session_start() is the very first line in admin/index.php (no whitespace or HTML before it).
- If you see a database error, check that require '../config.php' uses the correct relative path with ../ since the file is inside the admin/ subfolder.
- If the stylesheet is not loading, confirm the link tag uses ../style.css (one level up from the admin folder).
Help me debug my admin dashboard page
✔️ Awesome, I've got everything!
Great. Double check you have saved your file and uploaded it to the server.
ⓧ I'd like to double check the full code
Here is the complete admin/index.php file. Compare your version against this:
<?php
session_start();
if (!isset($_SESSION['user_role']) || $_SESSION['user_role'] !== 'admin') {
header('Location: ../login.php');
exit;
}
require '../config.php';
$category_filter = $_GET['category'] ?? 'all';
$sql = "SELECT c.id, c.title_ml, c.title_en, c.category, c.created_at FROM content c";
$params = [];
if ($category_filter !== 'all') {
$sql .= " WHERE c.category = :category";
$params['category'] = $category_filter;
}
$sql .= " ORDER BY c.created_at DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$entries = $stmt->fetchAll();
$keyword_stmt = $pdo->prepare("SELECT k.name FROM keywords k JOIN content_keywords ck ON k.id = ck.keyword_id WHERE ck.content_id = :content_id");
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard - Amma Library</title>
<link rel="stylesheet" href="../style.css">
</head>
<body>
<div class="admin-container">
<div class="admin-header">
<h1>Amma Library Admin</h1>
<nav class="admin-nav">
<span>Hello, <?php echo htmlspecialchars($_SESSION['username']); ?></span>
<a href="add.php">Add Content</a>
<a href="../logout.php">Logout</a>
</nav>
</div>
<?php if (isset($_GET['msg'])): ?>
<p class="success"><?php echo htmlspecialchars($_GET['msg']); ?></p>
<?php endif; ?>
<a href="add.php" class="btn-add">+ Add New Content</a>
<div class="filter-tabs">
<a href="index.php?category=all" class="<?php echo $category_filter === 'all' ? 'active' : ''; ?>">All</a>
<a href="index.php?category=story" class="<?php echo $category_filter === 'story' ? 'active' : ''; ?>">Stories</a>
<a href="index.php?category=anecdote" class="<?php echo $category_filter === 'anecdote' ? 'active' : ''; ?>">Anecdotes</a>
<a href="index.php?category=teaching" class="<?php echo $category_filter === 'teaching' ? 'active' : ''; ?>">Teachings</a>
</div>
<table class="content-table">
<thead>
<tr>
<th>Title (Malayalam)</th>
<th>Title (English)</th>
<th>Category</th>
<th>Keywords</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($entries)): ?>
<tr><td colspan="5">No content yet. Add your first entry!</td></tr>
<?php endif; ?>
<?php foreach ($entries as $entry): ?>
<?php
$keyword_stmt->execute(['content_id' => $entry['id']]);
$keywords = $keyword_stmt->fetchAll(PDO::FETCH_COLUMN);
?>
<tr>
<td class="malayalam"><?php echo htmlspecialchars($entry['title_ml']); ?></td>
<td><?php echo htmlspecialchars($entry['title_en']); ?></td>
<td><?php echo htmlspecialchars(ucfirst($entry['category'])); ?></td>
<td>
<?php foreach ($keywords as $kw): ?>
<span class="keyword-tag"><?php echo htmlspecialchars($kw); ?></span>
<?php endforeach; ?>
</td>
<td class="actions">
<a href="edit.php?id=<?php echo $entry['id']; ?>" class="edit-link">Edit</a>
<a href="delete.php?id=<?php echo $entry['id']; ?>" class="delete-link">Delete</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</body>
</html>
Your dashboard is live and ready to display content. Next up, you will build the form that lets you add new bilingual entries to the database.
Build the Add Content Form
Your admin dashboard is live and showing an empty content table. Now it needs a way to actually add entries to the library.
In this step, you will build the form that lets an admin submit bilingual content. The form captures Malayalam text alongside its English translation, assigns a category, and tags entries with keywords for future searching.
In this step, get ready to:
- Create the HTML form with fields for Malayalam and English content.
- Write the PHP form handler that validates input, inserts content, and processes keywords.
- Apply the Noto Sans Malayalam font to Malayalam input fields.
Create the HTML form structure
The add content form needs six fields to capture a complete bilingual entry. Malayalam and English each get a title and a body field, plus a category selector and a keywords input.
- Create a new file called add.php inside your admin/ folder.
- Paste the following HTML form code into the file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add Content - Amma Library</title>
<link rel="stylesheet" href="../style.css">
</head>
<body>
<div class="admin-container">
<div class="admin-header">
<h1>Amma Library Admin</h1>
<nav class="admin-nav">
<a href="index.php">Dashboard</a>
<a href="../logout.php">Logout</a>
</nav>
</div>
<div class="form-container">
<h2>Add New Content</h2>
<?php if ($error): ?>
<p class="error"><?php echo htmlspecialchars($error); ?></p>
<?php endif; ?>
<form method="POST" action="add.php">
<label for="title_ml">Title (Malayalam)</label>
<input type="text" id="title_ml" name="title_ml" class="malayalam-input" value="<?php echo htmlspecialchars($_POST['title_ml'] ?? ''); ?>" required>
<label for="title_en">Title (English)</label>
<input type="text" id="title_en" name="title_en" value="<?php echo htmlspecialchars($_POST['title_en'] ?? ''); ?>" required>
<label for="body_ml">Body (Malayalam)</label>
<textarea id="body_ml" name="body_ml" class="malayalam-input" required><?php echo htmlspecialchars($_POST['body_ml'] ?? ''); ?></textarea>
<label for="body_en">Body (English)</label>
<textarea id="body_en" name="body_en" required><?php echo htmlspecialchars($_POST['body_en'] ?? ''); ?></textarea>
<label for="category">Category</label>
<select id="category" name="category" required>
<option value="">Select category</option>
<option value="story" <?php echo (($_POST['category'] ?? '') === 'story') ? 'selected' : ''; ?>>Story</option>
<option value="anecdote" <?php echo (($_POST['category'] ?? '') === 'anecdote') ? 'selected' : ''; ?>>Anecdote</option>
<option value="teaching" <?php echo (($_POST['category'] ?? '') === 'teaching') ? 'selected' : ''; ?>>Teaching</option>
</select>
<label for="keywords">Keywords (comma-separated)</label>
<input type="text" id="keywords" name="keywords" placeholder="e.g. compassion, devotion, karma" value="<?php echo htmlspecialchars($_POST['keywords'] ?? ''); ?>">
<button type="submit">Add Content</button>
</form>
</div>
</div>
</body>
</html>
What does this form do?
- The title_ml and body_ml fields use the malayalam-input class so they render in the Noto Sans Malayalam font.
- The category dropdown restricts entries to Story, Anecdote, or Teaching.
- Each field uses htmlspecialchars($_POST['...'] ?? '') to re-fill the form with the learner's input if validation fails, preventing data loss on errors.
- Keywords are entered as a simple comma-separated string. The PHP handler will split and process them individually.
This HTML form is the bottom half of the file. Next, you will add the PHP logic at the top that processes form submissions.
Write the PHP form handler
When the admin submits the form, PHP needs to validate the input, insert the content into the database, and process each keyword. The handler goes above the HTML in the same file.
- In admin/add.php, add the following PHP block at the very top of the file, above the <!DOCTYPE html> line:
<?php
session_start();
if (!isset($_SESSION['user_role']) || $_SESSION['user_role'] !== 'admin') {
header('Location: ../login.php');
exit;
}
require '../config.php';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title_ml = trim($_POST['title_ml'] ?? '');
$title_en = trim($_POST['title_en'] ?? '');
$body_ml = trim($_POST['body_ml'] ?? '');
$body_en = trim($_POST['body_en'] ?? '');
$category = $_POST['category'] ?? '';
$keywords_str = trim($_POST['keywords'] ?? '');
if (empty($title_ml) || empty($title_en) || empty($body_ml) || empty($body_en) || empty($category)) {
$error = 'All fields except keywords are required.';
} else {
$stmt = $pdo->prepare("INSERT INTO content (title_ml, title_en, body_ml, body_en, category, created_at, updated_at) VALUES (:title_ml, :title_en, :body_ml, :body_en, :category, NOW(), NOW())");
$stmt->execute([
'title_ml' => $title_ml,
'title_en' => $title_en,
'body_ml' => $body_ml,
'body_en' => $body_en,
'category' => $category
]);
$content_id = $pdo->lastInsertId();
if (!empty($keywords_str)) {
$keywords = array_map('trim', explode(',', $keywords_str));
$keywords = array_filter($keywords);
$find_kw = $pdo->prepare("SELECT id FROM keywords WHERE name = :name");
$insert_kw = $pdo->prepare("INSERT INTO keywords (name) VALUES (:name)");
$link_kw = $pdo->prepare("INSERT INTO content_keywords (content_id, keyword_id) VALUES (:content_id, :keyword_id)");
foreach ($keywords as $kw) {
$kw = mb_strtolower($kw, 'UTF-8');
$find_kw->execute(['name' => $kw]);
$existing = $find_kw->fetch();
if ($existing) {
$keyword_id = $existing['id'];
} else {
$insert_kw->execute(['name' => $kw]);
$keyword_id = $pdo->lastInsertId();
}
$link_kw->execute([
'content_id' => $content_id,
'keyword_id' => $keyword_id
]);
}
}
header('Location: index.php?msg=Content added successfully');
exit;
}
}
?>
What does this handler do?
- The session check at the top redirects non-admin users back to the login page.
- On a POST request, the handler trims each input and checks that all required fields are filled. If anything is empty, it sets $error so the form redisplays with an error message.
- The INSERT INTO content statement uses a prepared statement with named placeholders. This prevents SQL injection by separating the query from user data.
- After inserting content, lastInsertId() captures the new row's ID so keywords can be linked to it.
- The keyword loop splits the comma-separated string, lowercases each keyword with mb_strtolower(), checks if it already exists in the keywords table, inserts it if new, then creates the junction table link.
- Save admin/add.php and upload it to your server via FTP.
- In your browser, log in as admin and click + Add New Content on the dashboard.
- You should see a clean form with fields for Title (Malayalam), Title (English), Body (Malayalam), Body (English), a Category dropdown, and a Keywords input.
Seeing a blank page or error?
- Make sure the PHP block is at the very top of the file with no blank lines or spaces before <?php. Even a single space before the opening tag causes header errors.
- Check that require '../config.php' uses the correct relative path from the admin/ subfolder.
- If you see a database error, confirm your config.php credentials are correct and the amma_library database exists.
Help me debug this
- Test the form by entering a sample entry. Type a Malayalam title, an English title, fill in both body fields with a short sentence, select Story as the category, and type compassion, devotion in the keywords field.
- Click Add Content.
- You should be redirected to the dashboard with a green success message reading "Content added successfully" and your new entry visible in the table.
Form submits but nothing appears on the dashboard?
- Check that all required fields (both titles, both bodies, and category) are filled in. Leaving any one empty triggers the validation error.
- Verify the table name in your INSERT statement matches exactly: content (not contents or entries).
- If keywords appear empty in the dashboard, check that the content_keywords junction table was created correctly in Step 1.
Help me trace this issue
Apply Noto Sans Malayalam font to the form fields
The Malayalam title and body fields need to render in a proper Malayalam typeface. Your style.css already handles this.
- Open style.css and find the @import line at the top of the file:
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+Malayalam:wght@400;700&display=swap');
- Find the .malayalam-input class further down:
.malayalam-input {
font-family: 'Noto Sans Malayalam', sans-serif;
font-size: 18px;
}
How does the font reach the form?
The @import at the top of style.css loads Noto Sans Malayalam from Google Fonts. The .malayalam-input class applies that font at 18px to any element that uses it.
In your form, the title_ml input and body_ml textarea both have class="malayalam-input". This makes Malayalam text readable as you type, with proper glyph rendering and adequate sizing for the script's complex characters.
- Return to your add content form in the browser and type a few Malayalam characters in the Title (Malayalam) field.
- The text should appear in the Noto Sans Malayalam font at a comfortable reading size, distinct from the English fields which use the default system font.
Malayalam text looks like boxes or default font?
- Confirm your style.css file has the @import line as the very first line. CSS imports must come before any other rules.
- Check that admin/add.php links to ../style.css (one directory up from the admin/ folder).
- Your server needs internet access to fetch the Google Fonts file. If you are working offline, the font will not load.
Help me fix my Malayalam font rendering
✔️ Awesome, I've got everything!
Great. Double check you have saved admin/add.php and uploaded it to your server.
ⓧ I'd like to double check the full code
Here is the complete admin/add.php file. Compare your version against this:
<?php
session_start();
if (!isset($_SESSION['user_role']) || $_SESSION['user_role'] !== 'admin') {
header('Location: ../login.php');
exit;
}
require '../config.php';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title_ml = trim($_POST['title_ml'] ?? '');
$title_en = trim($_POST['title_en'] ?? '');
$body_ml = trim($_POST['body_ml'] ?? '');
$body_en = trim($_POST['body_en'] ?? '');
$category = $_POST['category'] ?? '';
$keywords_str = trim($_POST['keywords'] ?? '');
if (empty($title_ml) || empty($title_en) || empty($body_ml) || empty($body_en) || empty($category)) {
$error = 'All fields except keywords are required.';
} else {
$stmt = $pdo->prepare("INSERT INTO content (title_ml, title_en, body_ml, body_en, category, created_at, updated_at) VALUES (:title_ml, :title_en, :body_ml, :body_en, :category, NOW(), NOW())");
$stmt->execute([
'title_ml' => $title_ml,
'title_en' => $title_en,
'body_ml' => $body_ml,
'body_en' => $body_en,
'category' => $category
]);
$content_id = $pdo->lastInsertId();
if (!empty($keywords_str)) {
$keywords = array_map('trim', explode(',', $keywords_str));
$keywords = array_filter($keywords);
$find_kw = $pdo->prepare("SELECT id FROM keywords WHERE name = :name");
$insert_kw = $pdo->prepare("INSERT INTO keywords (name) VALUES (:name)");
$link_kw = $pdo->prepare("INSERT INTO content_keywords (content_id, keyword_id) VALUES (:content_id, :keyword_id)");
foreach ($keywords as $kw) {
$kw = mb_strtolower($kw, 'UTF-8');
$find_kw->execute(['name' => $kw]);
$existing = $find_kw->fetch();
if ($existing) {
$keyword_id = $existing['id'];
} else {
$insert_kw->execute(['name' => $kw]);
$keyword_id = $pdo->lastInsertId();
}
$link_kw->execute([
'content_id' => $content_id,
'keyword_id' => $keyword_id
]);
}
}
header('Location: index.php?msg=Content added successfully');
exit;
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add Content - Amma Library</title>
<link rel="stylesheet" href="../style.css">
</head>
<body>
<div class="admin-container">
<div class="admin-header">
<h1>Amma Library Admin</h1>
<nav class="admin-nav">
<a href="index.php">Dashboard</a>
<a href="../logout.php">Logout</a>
</nav>
</div>
<div class="form-container">
<h2>Add New Content</h2>
<?php if ($error): ?>
<p class="error"><?php echo htmlspecialchars($error); ?></p>
<?php endif; ?>
<form method="POST" action="add.php">
<label for="title_ml">Title (Malayalam)</label>
<input type="text" id="title_ml" name="title_ml" class="malayalam-input" value="<?php echo htmlspecialchars($_POST['title_ml'] ?? ''); ?>" required>
<label for="title_en">Title (English)</label>
<input type="text" id="title_en" name="title_en" value="<?php echo htmlspecialchars($_POST['title_en'] ?? ''); ?>" required>
<label for="body_ml">Body (Malayalam)</label>
<textarea id="body_ml" name="body_ml" class="malayalam-input" required><?php echo htmlspecialchars($_POST['body_ml'] ?? ''); ?></textarea>
<label for="body_en">Body (English)</label>
<textarea id="body_en" name="body_en" required><?php echo htmlspecialchars($_POST['body_en'] ?? ''); ?></textarea>
<label for="category">Category</label>
<select id="category" name="category" required>
<option value="">Select category</option>
<option value="story" <?php echo (($_POST['category'] ?? '') === 'story') ? 'selected' : ''; ?>>Story</option>
<option value="anecdote" <?php echo (($_POST['category'] ?? '') === 'anecdote') ? 'selected' : ''; ?>>Anecdote</option>
<option value="teaching" <?php echo (($_POST['category'] ?? '') === 'teaching') ? 'selected' : ''; ?>>Teaching</option>
</select>
<label for="keywords">Keywords (comma-separated)</label>
<input type="text" id="keywords" name="keywords" placeholder="e.g. compassion, devotion, karma" value="<?php echo htmlspecialchars($_POST['keywords'] ?? ''); ?>">
<button type="submit">Add Content</button>
</form>
</div>
</div>
</body>
</html>
Your content library now has a working input form. Next, you will build the edit and delete pages to complete the full CRUD cycle.
Build Edit and Delete
Your admin panel can now add new bilingual content to the database. That covers the C in CRUD (Create, Read, Update, Delete).
Content management is only useful if you can fix mistakes and remove outdated entries. In this step, you will build the edit form that pre-fills existing data for updates, and the delete page that safely removes entries along with their keyword associations.
In this step, get ready to:
- Create the edit page that loads and updates existing content entries.
- Create the delete page with a confirmation step.
- Test the full CRUD cycle from add to edit to delete.
Create the edit page
The edit page works like the add page, but with one extra responsibility. It needs to load the existing content entry first, pre-fill every form field with current values, and then process updates on submission.
- Open admin/edit.php in your text editor and add the session check, entry loading, and keyword fetching logic:
<?php
session_start();
if (!isset($_SESSION['user_role']) || $_SESSION['user_role'] !== 'admin') {
header('Location: ../login.php');
exit;
}
require '../config.php';
// Get the content ID from the URL query string
$id = (int)($_GET['id'] ?? 0);
if ($id === 0) {
header('Location: index.php');
exit;
}
// Load the content entry from the database
$stmt = $pdo->prepare("SELECT * FROM content WHERE id = :id");
$stmt->execute(['id' => $id]);
$entry = $stmt->fetch();
if (!$entry) {
header('Location: index.php?msg=Content not found');
exit;
}
// Fetch existing keywords for this entry as a comma-separated string
$kw_stmt = $pdo->prepare("SELECT k.name FROM keywords k JOIN content_keywords ck ON k.id = ck.keyword_id WHERE ck.content_id = :content_id");
$kw_stmt->execute(['content_id' => $id]);
$existing_keywords = $kw_stmt->fetchAll(PDO::FETCH_COLUMN);
$keywords_str = implode(', ', $existing_keywords);
$error = '';
What does this code do?
The session check at the top is identical to your other admin pages. It redirects non-admin visitors to the login page.
The $id = (int)($_GET['id'] ?? 0) line reads the content ID from the URL (e.g. edit.php?id=3) and casts it to an integer for safety. If the entry is not found, the user gets redirected back to the dashboard.
The keyword query joins the keywords and content_keywords tables to retrieve all keyword names for this entry. implode(', ', ...) turns the array into a comma-separated string the form can display.
- Below the $error = ''; line, add the POST handler that validates input, updates the content, and refreshes keyword associations:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title_ml = trim($_POST['title_ml'] ?? '');
$title_en = trim($_POST['title_en'] ?? '');
$body_ml = trim($_POST['body_ml'] ?? '');
$body_en = trim($_POST['body_en'] ?? '');
$category = $_POST['category'] ?? '';
$keywords_str = trim($_POST['keywords'] ?? '');
if (empty($title_ml) || empty($title_en) || empty($body_ml) || empty($body_en) || empty($category)) {
$error = 'All fields except keywords are required.';
} else {
// Update the content entry
$stmt = $pdo->prepare("UPDATE content SET title_ml = :title_ml, title_en = :title_en, body_ml = :body_ml, body_en = :body_en, category = :category, updated_at = NOW() WHERE id = :id");
$stmt->execute([
'title_ml' => $title_ml,
'title_en' => $title_en,
'body_ml' => $body_ml,
'body_en' => $body_en,
'category' => $category,
'id' => $id
]);
// Remove old keyword associations and rebuild them
$pdo->prepare("DELETE FROM content_keywords WHERE content_id = :content_id")->execute(['content_id' => $id]);
if (!empty($keywords_str)) {
$keywords = array_map('trim', explode(',', $keywords_str));
$keywords = array_filter($keywords);
$find_kw = $pdo->prepare("SELECT id FROM keywords WHERE name = :name");
$insert_kw = $pdo->prepare("INSERT INTO keywords (name) VALUES (:name)");
$link_kw = $pdo->prepare("INSERT INTO content_keywords (content_id, keyword_id) VALUES (:content_id, :keyword_id)");
foreach ($keywords as $kw) {
$kw = mb_strtolower($kw, 'UTF-8');
$find_kw->execute(['name' => $kw]);
$existing = $find_kw->fetch();
if ($existing) {
$keyword_id = $existing['id'];
} else {
$insert_kw->execute(['name' => $kw]);
$keyword_id = $pdo->lastInsertId();
}
$link_kw->execute([
'content_id' => $id,
'keyword_id' => $keyword_id
]);
}
}
header('Location: index.php?msg=Content updated successfully');
exit;
}
}
?>
What does this code do?
The UPDATE query uses a prepared statement to safely modify the content row. It also sets updated_at = NOW() so you always know when content was last changed.
For keywords, the simplest approach is to delete all existing associations and rebuild them from scratch. This avoids complex diffing logic. The find-or-create keyword loop is identical to the one in add.php.
- Below the closing ?> tag, add the HTML form that pre-fills fields with current values:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Content - Amma Library</title>
<link rel="stylesheet" href="../style.css">
</head>
<body>
<div class="admin-container">
<div class="admin-header">
<h1>Amma Library Admin</h1>
<nav class="admin-nav">
<a href="index.php">Dashboard</a>
<a href="../logout.php">Logout</a>
</nav>
</div>
<div class="form-container">
<h2>Edit Content</h2>
<?php if ($error): ?>
<p class="error"><?php echo htmlspecialchars($error); ?></p>
<?php endif; ?>
<form method="POST" action="edit.php?id=<?php echo $id; ?>">
<label for="title_ml">Title (Malayalam)</label>
<input type="text" id="title_ml" name="title_ml" class="malayalam-input" value="<?php echo htmlspecialchars($_POST['title_ml'] ?? $entry['title_ml']); ?>" required>
<label for="title_en">Title (English)</label>
<input type="text" id="title_en" name="title_en" value="<?php echo htmlspecialchars($_POST['title_en'] ?? $entry['title_en']); ?>" required>
<label for="body_ml">Body (Malayalam)</label>
<textarea id="body_ml" name="body_ml" class="malayalam-input" required><?php echo htmlspecialchars($_POST['body_ml'] ?? $entry['body_ml']); ?></textarea>
<label for="body_en">Body (English)</label>
<textarea id="body_en" name="body_en" required><?php echo htmlspecialchars($_POST['body_en'] ?? $entry['body_en']); ?></textarea>
<label for="category">Category</label>
<select id="category" name="category" required>
<option value="">Select category</option>
<option value="story" <?php echo (($_POST['category'] ?? $entry['category']) === 'story') ? 'selected' : ''; ?>>Story</option>
<option value="anecdote" <?php echo (($_POST['category'] ?? $entry['category']) === 'anecdote') ? 'selected' : ''; ?>>Anecdote</option>
<option value="teaching" <?php echo (($_POST['category'] ?? $entry['category']) === 'teaching') ? 'selected' : ''; ?>>Teaching</option>
</select>
<label for="keywords">Keywords (comma-separated)</label>
<input type="text" id="keywords" name="keywords" value="<?php echo htmlspecialchars($_POST['keywords'] ?? $keywords_str); ?>">
<button type="submit">Update Content</button>
</form>
</div>
</div>
</body>
</html>
How does the pre-filling work?
Each input's value attribute uses the null coalescing operator: $_POST['field'] ?? $entry['field']. On initial page load (GET request), $_POST is empty so the database values show. If validation fails on submit, the form re-displays what the user typed.
The form action includes ?id=<?php echo $id; ?> so the ID persists through form submission.
- Save admin/edit.php and upload it to your server.
- In your browser, go to the admin dashboard and click the Edit link on any content entry you added earlier. You should see the form pre-filled with that entry's Malayalam title, English title, body text, category, and keywords.
Seeing a blank page or error?
Check that the file starts with <?php on line 1 with no whitespace before it. Also verify that require '../config.php' uses the correct relative path from the admin/ folder.
If the form loads but keywords are missing, confirm the entry actually has keyword associations in your database.
help me debug my edit.php page
✔️ Awesome, I've got everything!
Great. Make sure you have saved your file before moving on.
ⓧ I'd like to double check the full code
<?php
session_start();
if (!isset($_SESSION['user_role']) || $_SESSION['user_role'] !== 'admin') {
header('Location: ../login.php');
exit;
}
require '../config.php';
$id = (int)($_GET['id'] ?? 0);
if ($id === 0) {
header('Location: index.php');
exit;
}
$stmt = $pdo->prepare("SELECT * FROM content WHERE id = :id");
$stmt->execute(['id' => $id]);
$entry = $stmt->fetch();
if (!$entry) {
header('Location: index.php?msg=Content not found');
exit;
}
$kw_stmt = $pdo->prepare("SELECT k.name FROM keywords k JOIN content_keywords ck ON k.id = ck.keyword_id WHERE ck.content_id = :content_id");
$kw_stmt->execute(['content_id' => $id]);
$existing_keywords = $kw_stmt->fetchAll(PDO::FETCH_COLUMN);
$keywords_str = implode(', ', $existing_keywords);
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title_ml = trim($_POST['title_ml'] ?? '');
$title_en = trim($_POST['title_en'] ?? '');
$body_ml = trim($_POST['body_ml'] ?? '');
$body_en = trim($_POST['body_en'] ?? '');
$category = $_POST['category'] ?? '';
$keywords_str = trim($_POST['keywords'] ?? '');
if (empty($title_ml) || empty($title_en) || empty($body_ml) || empty($body_en) || empty($category)) {
$error = 'All fields except keywords are required.';
} else {
$stmt = $pdo->prepare("UPDATE content SET title_ml = :title_ml, title_en = :title_en, body_ml = :body_ml, body_en = :body_en, category = :category, updated_at = NOW() WHERE id = :id");
$stmt->execute([
'title_ml' => $title_ml,
'title_en' => $title_en,
'body_ml' => $body_ml,
'body_en' => $body_en,
'category' => $category,
'id' => $id
]);
$pdo->prepare("DELETE FROM content_keywords WHERE content_id = :content_id")->execute(['content_id' => $id]);
if (!empty($keywords_str)) {
$keywords = array_map('trim', explode(',', $keywords_str));
$keywords = array_filter($keywords);
$find_kw = $pdo->prepare("SELECT id FROM keywords WHERE name = :name");
$insert_kw = $pdo->prepare("INSERT INTO keywords (name) VALUES (:name)");
$link_kw = $pdo->prepare("INSERT INTO content_keywords (content_id, keyword_id) VALUES (:content_id, :keyword_id)");
foreach ($keywords as $kw) {
$kw = mb_strtolower($kw, 'UTF-8');
$find_kw->execute(['name' => $kw]);
$existing = $find_kw->fetch();
if ($existing) {
$keyword_id = $existing['id'];
} else {
$insert_kw->execute(['name' => $kw]);
$keyword_id = $pdo->lastInsertId();
}
$link_kw->execute([
'content_id' => $id,
'keyword_id' => $keyword_id
]);
}
}
header('Location: index.php?msg=Content updated successfully');
exit;
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Content - Amma Library</title>
<link rel="stylesheet" href="../style.css">
</head>
<body>
<div class="admin-container">
<div class="admin-header">
<h1>Amma Library Admin</h1>
<nav class="admin-nav">
<a href="index.php">Dashboard</a>
<a href="../logout.php">Logout</a>
</nav>
</div>
<div class="form-container">
<h2>Edit Content</h2>
<?php if ($error): ?>
<p class="error"><?php echo htmlspecialchars($error); ?></p>
<?php endif; ?>
<form method="POST" action="edit.php?id=<?php echo $id; ?>">
<label for="title_ml">Title (Malayalam)</label>
<input type="text" id="title_ml" name="title_ml" class="malayalam-input" value="<?php echo htmlspecialchars($_POST['title_ml'] ?? $entry['title_ml']); ?>" required>
<label for="title_en">Title (English)</label>
<input type="text" id="title_en" name="title_en" value="<?php echo htmlspecialchars($_POST['title_en'] ?? $entry['title_en']); ?>" required>
<label for="body_ml">Body (Malayalam)</label>
<textarea id="body_ml" name="body_ml" class="malayalam-input" required><?php echo htmlspecialchars($_POST['body_ml'] ?? $entry['body_ml']); ?></textarea>
<label for="body_en">Body (English)</label>
<textarea id="body_en" name="body_en" required><?php echo htmlspecialchars($_POST['body_en'] ?? $entry['body_en']); ?></textarea>
<label for="category">Category</label>
<select id="category" name="category" required>
<option value="">Select category</option>
<option value="story" <?php echo (($_POST['category'] ?? $entry['category']) === 'story') ? 'selected' : ''; ?>>Story</option>
<option value="anecdote" <?php echo (($_POST['category'] ?? $entry['category']) === 'anecdote') ? 'selected' : ''; ?>>Anecdote</option>
<option value="teaching" <?php echo (($_POST['category'] ?? $entry['category']) === 'teaching') ? 'selected' : ''; ?>>Teaching</option>
</select>
<label for="keywords">Keywords (comma-separated)</label>
<input type="text" id="keywords" name="keywords" value="<?php echo htmlspecialchars($_POST['keywords'] ?? $keywords_str); ?>">
<button type="submit">Update Content</button>
</form>
</div>
</div>
</body>
</html>
Create the delete page
Deleting content should never happen with a single accidental click. The delete page loads the entry, shows a confirmation screen with both titles, and only removes the data when the user explicitly confirms.
- Open admin/delete.php in your text editor and add the PHP logic:
<?php
session_start();
if (!isset($_SESSION['user_role']) || $_SESSION['user_role'] !== 'admin') {
header('Location: ../login.php');
exit;
}
require '../config.php';
// Get the content ID from the URL
$id = (int)($_GET['id'] ?? 0);
if ($id === 0) {
header('Location: index.php');
exit;
}
// Load just the fields we need for the confirmation message
$stmt = $pdo->prepare("SELECT id, title_en, title_ml FROM content WHERE id = :id");
$stmt->execute(['id' => $id]);
$entry = $stmt->fetch();
if (!$entry) {
header('Location: index.php?msg=Content not found');
exit;
}
// On POST confirmation, delete the entry and its keyword links
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$pdo->prepare("DELETE FROM content_keywords WHERE content_id = :content_id")->execute(['content_id' => $id]);
$pdo->prepare("DELETE FROM content WHERE id = :id")->execute(['id' => $id]);
header('Location: index.php?msg=Content deleted successfully');
exit;
}
?>
Why delete from content_keywords first?
The content_keywords junction table has a foreign key pointing to the content table. If you delete the content row first, those orphaned references would cause a constraint error. Deleting the associations first avoids this.
The page only processes the deletion on a POST request. A GET request (clicking the Delete link from the dashboard) just shows the confirmation screen below.
- Below the closing ?> tag, add the HTML confirmation page:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Delete Content - Amma Library</title>
<link rel="stylesheet" href="../style.css">
</head>
<body>
<div class="admin-container">
<div class="admin-header">
<h1>Amma Library Admin</h1>
<nav class="admin-nav">
<a href="index.php">Dashboard</a>
<a href="../logout.php">Logout</a>
</nav>
</div>
<div class="confirm-box">
<h2>Confirm Delete</h2>
<p>Are you sure you want to delete:</p>
<p><strong><?php echo htmlspecialchars($entry['title_en']); ?></strong></p>
<p class="malayalam" style="font-family: 'Noto Sans Malayalam', sans-serif;"><?php echo htmlspecialchars($entry['title_ml']); ?></p>
<br>
<form method="POST" action="delete.php?id=<?php echo $id; ?>" style="display: inline;">
<a href="index.php" class="btn-cancel">Cancel</a>
<button type="submit">Delete</button>
</form>
</div>
</div>
</body>
</html>
What does this page show?
The confirmation box displays both the English and Malayalam titles so the admin can verify they are deleting the correct entry. The Cancel link returns to the dashboard without deleting anything. The Delete button submits the form as a POST request, triggering the actual deletion.
- Save admin/delete.php and upload it to your server.
- In your browser, go to the admin dashboard and click Delete on any content entry. You should see the confirmation page with the entry's English title in bold and the Malayalam title below it, plus Cancel and Delete buttons.
Delete link not working?
Make sure the Delete links in admin/index.php point to delete.php?id=... with the correct ID. If the page redirects immediately back to the dashboard, check that the entry ID exists in the database.
help me fix my delete confirmation page
✔️ Awesome, I've got everything!
Great. Make sure you have saved your file before moving on.
ⓧ I'd like to double check the full code
<?php
session_start();
if (!isset($_SESSION['user_role']) || $_SESSION['user_role'] !== 'admin') {
header('Location: ../login.php');
exit;
}
require '../config.php';
$id = (int)($_GET['id'] ?? 0);
if ($id === 0) {
header('Location: index.php');
exit;
}
$stmt = $pdo->prepare("SELECT id, title_en, title_ml FROM content WHERE id = :id");
$stmt->execute(['id' => $id]);
$entry = $stmt->fetch();
if (!$entry) {
header('Location: index.php?msg=Content not found');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$pdo->prepare("DELETE FROM content_keywords WHERE content_id = :content_id")->execute(['content_id' => $id]);
$pdo->prepare("DELETE FROM content WHERE id = :id")->execute(['id' => $id]);
header('Location: index.php?msg=Content deleted successfully');
exit;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Delete Content - Amma Library</title>
<link rel="stylesheet" href="../style.css">
</head>
<body>
<div class="admin-container">
<div class="admin-header">
<h1>Amma Library Admin</h1>
<nav class="admin-nav">
<a href="index.php">Dashboard</a>
<a href="../logout.php">Logout</a>
</nav>
</div>
<div class="confirm-box">
<h2>Confirm Delete</h2>
<p>Are you sure you want to delete:</p>
<p><strong><?php echo htmlspecialchars($entry['title_en']); ?></strong></p>
<p class="malayalam" style="font-family: 'Noto Sans Malayalam', sans-serif;"><?php echo htmlspecialchars($entry['title_ml']); ?></p>
<br>
<form method="POST" action="delete.php?id=<?php echo $id; ?>" style="display: inline;">
<a href="index.php" class="btn-cancel">Cancel</a>
<button type="submit">Delete</button>
</form>
</div>
</div>
</body>
</html>
Test the full CRUD cycle
You now have all four CRUD operations in place. Time to verify they work together from start to finish.
- In your browser, go to the admin dashboard and click Add New Content.
- Fill in the form with a sample entry. For the Malayalam title, enter കഥ ഒന്ന്. For the English title, enter Story One.
- Enter any Malayalam text in the Malayalam body field and an English translation in the English body field.
- Select Story as the category.
- In the keywords field, type compassion, test.
- Click Add Content.
You should be redirected to the dashboard with a success message. Your new entry appears in the table with the Malayalam title, English title, category, and keyword tags.
- Click the Edit link on the entry you just added.
- Verify that all fields are pre-filled with the values you entered.
- Change the keywords field to compassion, kindness (removing test and adding kindness).
- Click Update Content.
The dashboard should show the updated keyword tags. The test tag is gone, replaced by kindness.
- Click the Delete link on the same entry.
- Verify that the confirmation page shows the English title in bold and the Malayalam title below it.
- Click the Delete button to confirm.
You are redirected to the dashboard with a success message. The entry is gone from the table.
What just happened?
You completed a full Create, Read, Update, Delete cycle. The content was added to the database, displayed on the dashboard, modified (with keyword associations rebuilt), and finally removed along with all its junction table entries.
Keywords not updating correctly?
If edited keywords are not reflected on the dashboard, check that the DELETE statement in edit.php uses content_keywords (the junction table), not the keywords table itself. You want to remove the associations, not the keyword entries other content might use.
If deletion fails with a database error, confirm you are deleting from content_keywords before deleting from content. The order matters because of foreign key constraints.
help me debug my CRUD operations
Secret mission
Add a Two-Column Preview
Your admin panel can add, edit, and delete content. But what does a story actually look like when someone reads it? Build a preview page that shows Malayalam and English side by side in a two-column layout, exactly how users will see it in Project 2.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your existing PHP + MySQL server, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- MySQL database amma_library with tables for content, keywords, content_keywords, and users.
- Project files on your server (config.php, login.php, logout.php, style.css, and the admin/ folder with all PHP files).
Keep everything running
No action needed. Choose this if you're still actively building or plan to continue with Project 2 (the user-facing frontend).
- Your database and files stay on your server, ready for you to build the reader-facing frontend.
- Your admin panel remains accessible at its current URL.
Pause - I'll come back to this later
Since this project uses your existing server, there is nothing to pause. Your files and database remain available whenever you return.
- If you want to restrict access while away, rename or delete config.php temporarily. Without it, none of the PHP pages can connect to the database.
- Keep a local backup of your project files on your computer in case you need to re-upload later.
Delete - I don't want to use this again
Remove all project resources from your server to start fresh.
- Delete the MySQL database by running this SQL command in phpMyAdmin or your MySQL command line:
DROP DATABASE amma_library;
- Delete all project files from your server. Use your FTP client or hosting file manager to remove the entire project folder (amma-library/ or whatever you named it) including config.php, login.php, logout.php, style.css, and the admin/ subfolder.
- If you also have a local copy of the project files on your computer, delete that folder as well.
Nice Work!
Nice Work!
You just built a fully functional admin panel for managing a bilingual content library. Your self-hosted system stores Malayalam stories and English translations in a secure MySQL database, ready for users to read in Project 2.
Here's what you learned:
- Built a MySQL database with normalized tables for bilingual content, keywords (via a junction table), and users.
- Implemented secure authentication with password hashing and session-based role management for admin and reader roles.
- Created a full admin panel with add, edit, delete, and category-filtered listing of content entries.
- Secret Mission: Added a two-column preview showing how content appears in the Malayalam/English side-by-side layout users will see.
Ready to quiz yourself?