🐘 PHP + CBTEditor — Full CRUD Guide
This guide shows how to use CBTEditor in a PHP + MySQL application for creating, reading, updating, and deleting rich documents with math and chemistry blocks. All code examples are real, working patterns — not pseudocode.
Insert new doc
Display saved doc
Edit & save changes
Remove doc
.js and .css files into your project,
add a few lines of PHP, and you're done. The editor is just a
<script> tag — it works with any backend.
Project Setup
Your project folder should look like this:
your-php-app/
├── cbt-editor/
│ ├── cbt-editor.min.js
│ ├── cbt-editor.min.css
│ └── fonts/
├── index.php ← list all documents
├── create.php ← new document form
├── edit.php ← edit existing document
├── view.php ← view document (read-only)
├── delete.php ← delete confirmation
├── save.php ← API: handles save (POST)
└── db.php ← database connection
Database Table
CREATE TABLE documents (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content JSON NOT NULL, -- the TipTap document JSON
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
JSON column (MySQL 5.7+) or TEXT column.
The JSON contains the full document — text, formatting, math blocks, chemistry blocks,
tables — everything. You never need to parse it on the server; just store it and serve it back.
CREATE — Insert a New Document
The user opens create.php, sees a blank CBTEditor, builds their document
(text + math + chemistry), and submits the form. The editor auto-syncs its JSON
into a hidden <input name="content"> — no save button needed.
create.php — The form page
<?php
// create.php — New document form
// The form POSTs to itself. On POST, save to DB and redirect.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_once 'db.php';
$title = $_POST['title'] ?? 'Untitled';
$content = $_POST['content']; // ← the editor's JSON, auto-synced
$stmt = $pdo->prepare('INSERT INTO documents (title, content) VALUES (?, ?)');
$stmt->execute([$title, $content]);
$newId = $pdo->lastInsertId();
header('Location: view.php?id=' . $newId);
exit;
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>New Document</title>
<link rel="stylesheet" href="cbt-editor/cbt-editor.min.css">
</head>
<body>
<h1>📝 Create New Document</h1>
<form method="POST">
<label>Title:</label>
<input type="text" name="title" placeholder="Document title..." required>
<label>Content:</label>
<div id="editor"></div>
<button type="submit">📤 Submit</button>
</form>
<script src="cbt-editor/cbt-editor.min.js"></script>
<script>
CBTEditor.setMathLiveFontsDir('cbt-editor/fonts');
new CBTEditor({
target: document.getElementById('editor'),
name: 'content' // ← auto-syncs JSON to hidden <input name="content">
});
</script>
</body>
</html>
How the save works (no fetch() needed)
When the form is submitted, $_POST['content'] contains the
full document JSON — including all math and chemistry blocks. PHP inserts
it directly into the database. No JavaScript fetch(), no save button,
no custom API endpoint. Just a normal HTML form.
fetch() → PHP inserts into documents table →
returns the new ID.
READ — Display a Saved Document
view.php?id=5 loads document #5 from the database and displays it.
Two modes: read-only (just view) or editable
(for the UPDATE workflow below).
view.php — Read-only display
<?php
require_once 'db.php';
$id = $_GET['id'] ?? null;
if (!$id) { die('Missing document ID'); }
$stmt = $pdo->prepare('SELECT * FROM documents WHERE id = ?');
$stmt->execute([$id]);
$doc = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$doc) { die('Document not found'); }
// The content is stored as a JSON string — decode it for the editor
$contentJSON = $doc['content'];
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title><?php echo htmlspecialchars($doc['title']); ?></title>
<link rel="stylesheet" href="cbt-editor/cbt-editor.min.css">
</head>
<body>
<h1><?php echo htmlspecialchars($doc['title']); ?></h1>
<p>
Created: <?php echo $doc['created_at']; ?> |
Updated: <?php echo $doc['updated_at']; ?> |
<a href="edit.php?id=<?php echo $id; ?>">✏️ Edit</a> |
<a href="delete.php?id=<?php echo $id; ?>">🗑 Delete</a>
</p>
<div id="editor"></div>
<script src="cbt-editor/cbt-editor.min.js"></script>
<script>
CBTEditor.setMathLiveFontsDir('cbt-editor/fonts');
// Load the saved document — math & chem blocks are EDITABLE
const savedDoc = <?php echo $contentJSON; ?>;
const editor = new CBTEditor({
target: document.getElementById('editor'),
initialContent: savedDoc, // ← reconstructions math/chem blocks
readOnly: true // ← read-only mode (no editing)
});
</script>
</body>
</html>
initialContent: savedDoc,
the math and chemistry blocks are reconstructed as fully interactive
editors, not static images or text. The quadratic formula is a live
MathLive field. The chemistry formula is a live formula builder.
To make it read-only: set
readOnly: true in the
CBTEditor options. The blocks still render visually but aren't clickable.
To make it editable: set
readOnly: false (or omit it).
Users can click math/chem blocks to edit them.
UPDATE — Edit an Existing Document
This is the most common workflow. User opens edit.php?id=5,
sees their previously saved document with all math/chem blocks live and
editable, makes changes, and submits the form. The updated JSON is sent
via a normal form POST — no JavaScript fetch() needed.
edit.php — The edit form
<?php
require_once 'db.php';
$id = $_GET['id'] ?? null;
if (!$id) { die('Missing document ID'); }
// ── Handle form submission (UPDATE) ────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = $_POST['title'] ?? 'Untitled';
$content = $_POST['content']; // ← editor auto-synced JSON
$stmt = $pdo->prepare(
'UPDATE documents SET title = ?, content = ?, updated_at = NOW() WHERE id = ?'
);
$stmt->execute([$title, $content, $id]);
header('Location: view.php?id=' . $id . '&saved=1');
exit;
}
// ── Load existing document ─────────────────────────────
$stmt = $pdo->prepare('SELECT * FROM documents WHERE id = ?');
$stmt->execute([$id]);
$doc = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$doc) { die('Document not found'); }
$contentJSON = $doc['content'];
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Edit: <?php echo htmlspecialchars($doc['title']); ?></title>
<link rel="stylesheet" href="cbt-editor/cbt-editor.min.css">
</head>
<body>
<h1>✏️ Edit Document</h1>
<form method="POST">
<label>Title:</label>
<input type="text" name="title"
value="<?php echo htmlspecialchars($doc['title']); ?>">
<label>Content:</label>
<div id="editor"></div>
<button type="submit">💾 Save Changes</button>
</form>
<script src="cbt-editor/cbt-editor.min.js"></script>
<script>
CBTEditor.setMathLiveFontsDir('cbt-editor/fonts');
const savedDoc = <?php echo $contentJSON; ?>;
new CBTEditor({
target: document.getElementById('editor'),
name: 'content', // ← auto-syncs to hidden <input name="content">
value: savedDoc // ← pre-load existing content (math/chem blocks ARE editable)
});
console.log('✅ Document #<?php echo $id; ?> loaded for editing.');
</script>
</body>
</html>
$_POST['content'] with the updated JSON → UPDATE query runs.
No fetch(), no custom API endpoint, just a normal HTML form.
The Round-Trip Guarantee
When edit.php loads, the saved JSON from the database is passed
to initialContent. The CBTEditor internally calls
importFromJSON(), which reconstructs the full ProseMirror document tree:
- Math blocks → live
<math-field>MathLive web components (click to open visual editor) - Chemistry blocks → live formula builders with coefficient spinners, subscript fields, arrow dropdowns
- Tables, lists, headings, bold/italic → all standard TipTap formatting
Nothing is ever flattened to static text or images — as long as the JSON contains
"type": "mathBlock" / "type": "chemBlock" nodes with their
attributes intact.
DELETE — Remove a Document
Simple PHP page with confirmation:
<?php
// delete.php?id=5
require_once 'db.php';
$id = $_GET['id'] ?? null;
if (!$id) { die('Missing document ID'); }
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Confirmed — delete it
$stmt = $pdo->prepare('DELETE FROM documents WHERE id = ?');
$stmt->execute([$id]);
header('Location: index.php?deleted=' . $id);
exit;
}
// Show confirmation page
$stmt = $pdo->prepare('SELECT title FROM documents WHERE id = ?');
$stmt->execute([$id]);
$doc = $stmt->fetch();
?>
<!DOCTYPE html>
<html>
<head><title>Delete Document</title></head>
<body>
<h1>🗑 Delete "<?php echo htmlspecialchars($doc['title']); ?>"?</h1>
<p>This cannot be undone. The math equations and chemistry formulas
in this document will be permanently deleted.</p>
<form method="post">
<button type="submit">Yes, Delete</button>
<a href="view.php?id=<?php echo $id; ?>">Cancel</a>
</form>
</body>
</html>
Full CRUD Flow — End to End
Database Connection (db.php)
<?php
// db.php — Shared database connection
$host = 'localhost';
$db = 'my_cbt_app';
$user = 'root';
$pass = '';
$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());
}
index.php — List All Documents
<?php
require_once 'db.php';
$stmt = $pdo->query(
'SELECT id, title, created_at, updated_at FROM documents ORDER BY updated_at DESC'
);
$docs = $stmt->fetchAll();
?>
<!DOCTYPE html>
<html>
<head><title>My Documents</title></head>
<body>
<h1>📚 My Documents</h1>
<a href="create.php">📝 Create New</a>
<?php if (isset($_GET['deleted'])): ?>
<p style="color:green">✅ Document #<?php echo $_GET['deleted']; ?> deleted.</p>
<?php endif; ?>
<table>
<tr><th>Title</th><th>Updated</th><th>Actions</th></tr>
<?php foreach ($docs as $doc): ?>
<tr>
<td><?php echo htmlspecialchars($doc['title']); ?></td>
<td><?php echo $doc['updated_at']; ?></td>
<td>
<a href="view.php?id=<?php echo $doc['id']; ?>">👁 View</a>
<a href="edit.php?id=<?php echo $doc['id']; ?>">✏️ Edit</a>
<a href="delete.php?id=<?php echo $doc['id']; ?>">🗑 Delete</a>
</td>
</tr>
<?php endforeach; ?>
</table>
</body>
</html>
Complete Request Flow
| Action | URL | Method | What Happens |
|---|---|---|---|
| Create | create.php → save.php |
GET → POST | User builds doc in editor → clicks Save → JSON sent to save.php → INSERT into DB → returns ID |
| Read | view.php?id=5 |
GET | PHP loads JSON from DB → echoed into <script> tag → editor renders with readOnly: true |
| Update | edit.php?id=5 → save.php |
GET → POST | PHP loads JSON → editor renders EDITABLE → user edits → clicks Save → JSON sent with id → UPDATE query |
| Delete | delete.php?id=5 |
GET → POST | Confirmation page → user confirms → DELETE query → redirect to index |
importFromJSON() handles all reconstruction.
📝 Real Example: CBT Exam System
This is the exact use case you described: a Computer-Based Test system where questions use the rich editor (with math & chemistry), and questions display as readable formatted HTML to students (not raw JSON gibberish).
Database Table
CREATE TABLE exam_questions (
id INT AUTO_INCREMENT PRIMARY KEY,
exam_id INT NOT NULL,
question JSON NOT NULL, -- CBTEditor document (the question body)
option_a TEXT NOT NULL, -- plain text or JSON if using editor
option_b TEXT NOT NULL,
option_c TEXT NOT NULL,
option_d TEXT NOT NULL,
correct_answer CHAR(1) NOT NULL, -- 'A', 'B', 'C', or 'D'
marks INT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE — Add a New Exam Question
The question body uses CBTEditor. Options can be plain text inputs or their own editors. Instructions are plain text.
<?php
// questions/create.php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_once '../db.php';
$stmt = $pdo->prepare(
'INSERT INTO exam_questions (exam_id, question, option_a, option_b, option_c, option_d, correct_answer, marks)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$_POST['exam_id'],
$_POST['question'], // ← CBTEditor auto-synced JSON
$_POST['option_a'],
$_POST['option_b'],
$_POST['option_c'],
$_POST['option_d'],
$_POST['correct_answer'],
$_POST['marks'] ?? 1
]);
header('Location: list.php?exam_id=' . $_POST['exam_id'] . '&added=1');
exit;
}
$exam_id = $_GET['exam_id'] ?? 0;
?>
<!DOCTYPE html><html><head>
<title>Add Question</title>
<link rel="stylesheet" href="../cbt-editor/cbt-editor.min.css">
</head><body>
<h1>📝 Add Exam Question</h1>
<form method="POST">
<input type="hidden" name="exam_id" value="<?php echo $exam_id; ?>">
<label>Instruction (plain text)</label>
<input type="text" name="instruction" placeholder="e.g. Choose the correct answer">
<label>Question (rich text + math + chemistry)</label>
<div id="question-editor"></div>
<label>Option A</label>
<input type="text" name="option_a" required>
<label>Option B</label>
<input type="text" name="option_b" required>
<label>Option C</label>
<input type="text" name="option_c" required>
<label>Option D</label>
<input type="text" name="option_d" required>
<label>Correct Answer</label>
<select name="correct_answer">
<option value="A">A</option><option value="B">B</option>
<option value="C">C</option><option value="D">D</option>
</select>
<label>Marks</label>
<input type="number" name="marks" value="1" min="1">
<button type="submit">💾 Save Question</button>
</form>
<script src="../cbt-editor/cbt-editor.min.js"></script>
<script>
CBTEditor.setMathLiveFontsDir('../cbt-editor/fonts');
new CBTEditor({
target: document.getElementById('question-editor'),
name: 'question' // ← auto-syncs to hidden <input name="question">
});
</script>
</body></html>
EDIT — Load Question Back for Editing
Load the saved JSON into the editor using value. The math/chem blocks come back editable.
<?php
// questions/edit.php?id=5
require_once '../db.php';
$id = $_GET['id'] ?? 0;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt = $pdo->prepare(
'UPDATE exam_questions SET question=?, option_a=?, option_b=?, option_c=?,
option_d=?, correct_answer=?, marks=?, updated_at=NOW() WHERE id=?'
);
$stmt->execute([
$_POST['question'], $_POST['option_a'], $_POST['option_b'],
$_POST['option_c'], $_POST['option_d'], $_POST['correct_answer'],
$_POST['marks'] ?? 1, $id
]);
header('Location: list.php?updated=1');
exit;
}
$stmt = $pdo->prepare('SELECT * FROM exam_questions WHERE id = ?');
$stmt->execute([$id]);
$q = $stmt->fetch();
if (!$q) die('Question not found');
$questionJSON = $q['question']; // ← JSON string from DB
?>
<!DOCTYPE html><html><head>
<title>Edit Question #<?php echo $id; ?></title>
<link rel="stylesheet" href="../cbt-editor/cbt-editor.min.css">
</head><body>
<h1>✏️ Edit Question #<?php echo $id; ?></h1>
<form method="POST">
<label>Question</label>
<div id="question-editor"></div>
<label>Option A</label>
<input type="text" name="option_a"
value="<?php echo htmlspecialchars($q['option_a']); ?>">
<label>Option B</label>
<input type="text" name="option_b"
value="<?php echo htmlspecialchars($q['option_b']); ?>">
<label>Option C</label>
<input type="text" name="option_c"
value="<?php echo htmlspecialchars($q['option_c']); ?>">
<label>Option D</label>
<input type="text" name="option_d"
value="<?php echo htmlspecialchars($q['option_d']); ?>">
<label>Correct Answer</label>
<select name="correct_answer">
<?php foreach(['A','B','C','D'] as $opt): ?>
<option value="<?php echo $opt; ?>"
<?php if($q['correct_answer']===$opt) echo 'selected'; ?>>
<?php echo $opt; ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit">💾 Save Changes</button>
</form>
<script src="../cbt-editor/cbt-editor.min.js"></script>
<script>
CBTEditor.setMathLiveFontsDir('../cbt-editor/fonts');
const savedQuestion = <?php echo $questionJSON; ?>;
new CBTEditor({
target: document.getElementById('question-editor'),
name: 'question', // auto-syncs JSON to hidden input
value: savedQuestion // ← math/chem blocks come back EDITABLE
});
</script>
</body></html>
DISPLAY — Show Question as Readable HTML (Not JSON!)
This is the critical part. When showing questions to students, you use
getHTML() to render the document as formatted HTML —
not raw JSON. You can do this server-side (save HTML alongside JSON) or client-side.
Approach A: Save HTML at creation time (recommended)
Add an question_html column to your table. Generate HTML when saving:
// In your save handler (create.php or edit.php POST):
$questionHTML = $editor->getHTML(); // ← client-side JS
// Send both JSON and HTML to server
// Or: generate HTML server-side if you have a ProseMirror PHP renderer
Approach B: Render on display page (simpler)
On the exam page, load the JSON and render as HTML using a lightweight read-only editor:
<?php
// exam/take.php — Student takes the exam
$stmt = $pdo->prepare('SELECT * FROM exam_questions WHERE exam_id = ? ORDER BY id');
$stmt->execute([$exam_id]);
$questions = $stmt->fetchAll();
?>
<!DOCTYPE html><html><head>
<title>Exam</title>
<link rel="stylesheet" href="../cbt-editor/cbt-editor.min.css">
</head><body>
<form method="POST" action="submit.php">
<?php foreach ($questions as $index => $q): ?>
<div class="question-card">
<h3>Question <?php echo $index + 1; ?>
(<?php echo $q['marks']; ?> mark<?php echo $q['marks']>1?'s':''; ?>)</h3>
<!-- Display the question as readable HTML -->
<div class="q-display" id="q-display-<?php echo $q['id']; ?>"></div>
<label><input type="radio" name="answer[<?php echo $q['id']; ?>]" value="A">
A) <?php echo htmlspecialchars($q['option_a']); ?></label><br>
<label><input type="radio" name="answer[<?php echo $q['id']; ?>]" value="B">
B) <?php echo htmlspecialchars($q['option_b']); ?></label><br>
<label><input type="radio" name="answer[<?php echo $q['id']; ?>]" value="C">
C) <?php echo htmlspecialchars($q['option_c']); ?></label><br>
<label><input type="radio" name="answer[<?php echo $q['id']; ?>]" value="D">
D) <?php echo htmlspecialchars($q['option_d']); ?></label>
</div>
<?php endforeach; ?>
<button type="submit">📤 Submit Exam</button>
</form>
<script src="../cbt-editor/cbt-editor.min.js"></script>
<script>
CBTEditor.setMathLiveFontsDir('../cbt-editor/fonts');
// Render each question as readable HTML using getHTML()
<?php foreach ($questions as $q): ?>
(function() {
const savedQ = <?php echo $q['question']; ?>;
const editor = new CBTEditor({
target: document.getElementById('q-display-<?php echo $q['id']; ?>'),
value: savedQ,
readOnly: true // ← students can't edit
});
// The editor renders the question as formatted HTML automatically.
// No JSON gibberish — students see proper headings, bold, italic,
// math equations, and chemistry formulas.
})();
<?php endforeach; ?>
</script>
</body></html>
readOnly: true when showing
questions to students. The editor renders the document as formatted HTML
with all math equations and chemistry formulas properly displayed. Students see
readable content — not JSON. The toolbar is still visible (for read-only viewing)
but editing is disabled.