Adding a WYSIWYG editor to a PHP application is four small problems wearing one coat: render the editor, get the HTML back on submit, store it, and put it on a page again without handing your users’ browsers to whoever typed into the box. The first two take about ten lines. The other two are where applications get hurt.
Everything below is the code that ships in the PHP sample in the download, plus the parts the sample deliberately leaves to you — and it is explicit about which is which.
1. Render the editor
Three tags and a constructor. No build step, no bundler, no Composer package.
<link rel="stylesheet" href="/richtexteditor/rte_theme_default.css" />
<script src="/richtexteditor/rte.js"></script>
<script src="/richtexteditor/plugins/all_plugins.js"></script>
<div id="div_editor1" style="width:min(960px,100%)"></div>
<script>
var editor1 = new RichTextEditor(document.getElementById("div_editor1"));
</script>Unzip the download into your web root so /richtexteditor/ resolves, and that is the whole install. If you serve the assets from a different path, pass it as the second argument.
2. Get the HTML back on submit
A contenteditable is not a form field, so a plain <form> post will not carry its content. The sample mirrors the editor into a hidden input on every change:
<form method="post">
<input name="htmlcode" id="inp_htmlcode" type="hidden" />
<div id="div_editor1"></div>
<button type="submit">Save</button>
</form>
<script>
var editor1 = new RichTextEditor(document.getElementById("div_editor1"));
editor1.attachEvent("change", function () {
document.getElementById("inp_htmlcode").value = editor1.getHTMLCode();
});
</script>Mirror on change, not on submit.Reading the editor inside a submit handler looks tidier and loses the last edit whenever the browser submits without firing your handler — an Enter keypress in another field, a programmatic form.submit(), an autofill. Keeping the hidden input continuously in step costs nothing and has no such edge.
On the PHP side the content is now an ordinary field:
$html = $_POST["htmlcode"] ?? "";Saving without a page reload
Same idea, no hidden input:
await fetch("save.php", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ htmlcode: editor1.getHTMLCode() }),
});Send a CSRF token with it exactly as you would for any other state-changing POST. The editor has no opinion about that and does not need one.
3. Store it
Editor output is HTML, so the column needs to hold it byte-for-byte: MEDIUMTEXT or LONGTEXT on MySQL with the connection and column in utf8mb4. A TEXT column tops out at 65,535 bytes, which a long document with a couple of embedded images will pass without warning — MySQL truncates silently unless strict mode is on, and the failure surfaces later as a document that ends mid-sentence.
$stmt = $pdo->prepare("UPDATE documents SET body = ? WHERE id = ?");
$stmt->execute([$html, $id]);Use a prepared statement, as with any other input. Note what this protects and what it does not: it makes the HTML safe for your database. It does nothing about what that HTML does in a browser, which is the next section and the one that matters.
Do not run htmlspecialchars() before storing. You would be storing the escaped text of the markup rather than the markup, and the document would come back as a page of visible angle brackets.
4. Render it again — the part that bites
The shipped sample echoes the posted HTML straight back so you can see the round trip working:
echo $_POST["htmlcode"]; // sample code — see belowThat is correct for a sample and wrong for an application. Whatever the editor filters on the way in, the string in your database is just a string by the time it reaches your template, and it can be replaced by anything that can write to that row — another endpoint, an import job, a migration, a second application sharing the database, a support tool. Output is where user HTML has to be made safe, because output is the only place you can be certain it is about to run.
In PHP that means an allowlist sanitiser at render time. The established one is HTML Purifier:
$config = HTMLPurifier_Config::createDefault();
$config->set("HTML.Allowed", "p,br,strong,em,u,s,h1,h2,h3,h4,ul,ol,li,blockquote,pre,code,a[href|title],img[src|alt|width|height],table,thead,tbody,tr,th,td,span[style],div[style]");
$config->set("CSS.AllowedProperties", "color,background-color,text-align,font-weight,font-style,text-decoration");
$config->set("Attr.AllowedFrameTargets", ["_blank"]);
echo (new HTMLPurifier($config))->purify($row["body"]);Allowlist, never blocklist. A blocklist has to anticipate every vector; an allowlist only has to name what your documents legitimately contain. The list above is a reasonable starting point for editor output — trim it rather than extend it.
Two specifics worth naming, because both survive filters that look thorough: <iframe srcdoc> carries a whole document in an attribute, and data:image/svg+xml is a script-bearing document that passes for an image. Neither is in the allowlist above, which is the point of using one.
Our editor also filters input, output and the live DOM — that work is described in a separate article. It does not remove the need for the server-side pass: a filter that runs in the author’s browser cannot speak for content that arrived any other way.
5. Image uploads
Point the editor at an endpoint and it posts the file there, expecting READY:<url> back:
var editor1 = new RichTextEditor("#div_editor1", {
file_upload_handler: "rte-upload.php"
});The sample handler moves the file into an imageuploads folder and echoes the path. Read it before you ship it. As written it renames every upload to .jpg whatever it actually is (there is a // TODO: convert any image to JPG in the file saying so), it does not check the type, and it creates the directory 0777. It demonstrates the protocol; it is not a production handler.
A hardened version keeps the protocol and fixes the three:
$file = $_FILES["fileforphp"] ?? null;
if (!$file || $file["error"] !== UPLOAD_ERR_OK) { http_response_code(400); exit; }
if ($file["size"] > 8 * 1024 * 1024) { http_response_code(413); exit; }
// Trust the bytes, not the filename and not the browser's Content-Type.
$info = @getimagesize($file["tmp_name"]);
$ext = [IMAGETYPE_JPEG => "jpg", IMAGETYPE_PNG => "png",
IMAGETYPE_GIF => "gif", IMAGETYPE_WEBP => "webp"][$info[2] ?? 0] ?? null;
if (!$ext) { http_response_code(415); exit; } // not an image we accept
$name = bin2hex(random_bytes(16)) . "." . $ext;
$dir = __DIR__ . "/imageuploads";
if (!is_dir($dir)) { mkdir($dir, 0755, true); }
move_uploaded_file($file["tmp_name"], "$dir/$name");
echo "READY:imageuploads/$name";getimagesize() reads the actual bytes, so a PHP script renamed .pngfails here rather than landing in a web-served directory. Generate the stored name yourself — never build a path out of $file["name"], which is attacker-controlled. And serve the upload directory with execution off:
# .htaccess in imageuploads/
php_flag engine off
<FilesMatch "\.ph(p[0-9]?|tml|ar)$">
Require all denied
</FilesMatch>The whole thing, as a checklist
- Three tags, one constructor — no build step.
- Mirror to a hidden input on
change, not on submit. MEDIUMTEXT/LONGTEXT,utf8mb4, prepared statements.- Never
htmlspecialchars()on the way in. - Allowlist sanitise on the way out, every time, regardless of what filtered it on the way in.
- Uploads: verify by bytes, rename yourself, no execution in the upload directory.
The runnable version of steps 1, 2 and 5 is in the PHP sample in the download — form.php, ajax.php and rte-upload.php. Steps 3 and 4 are yours, and they are the two that decide whether the feature is safe.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.