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

📝 CREATE
Insert new doc
👁 READ
Display saved doc
✏️ UPDATE
Edit & save changes
🗑 DELETE
Remove doc
💡 No PHP files to write. The examples below show you the patterns. Drop CBTEditor's .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
);
🔑 Key decision: Store the document as JSON in a 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.

}
✅ CREATE summary: User types → clicks Save → JSON sent via 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>
⚠️ Critical: When you pass 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>
✅ UPDATE summary: Page loads → document appears with live math/chem blocks → user edits text, clicks equations, changes formulas → clicks the form's Submit button → PHP receives $_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:

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

ActionURLMethodWhat Happens
Create create.phpsave.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=5save.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
⚠️ Remember: The document JSON stored in the database contains everything — text, formatting, math equations (as LaTeX strings), chemistry formulas (as JSON trees), table data. Never modify the JSON structure on the server side. Treat it as an opaque blob. CBTEditor's 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>
✅ Display summary: Use 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.