Previous Article
How to Create a Custom WordPress Widget (Classic and Block Editor)
8 September 2018
Before the code, the thing you need to know: AngularJS (the 1.x line) reached end of life on 31 December 2021. Google no longer ships security patches, and it will never receive fixes for vulnerabilities discovered from now on.
If you are starting something new, do not use it. If you are maintaining an existing app, the migration notes at the end are the useful part. Either way, the underlying task — submit a form, save it in MySQL, render the list without a page reload — is worth doing properly.
The AngularJS example that circulates for this, including the earlier version of this article, is broken on any supported version even setting EOL aside:
// Removed in Angular 1.6
$http.get("index.php?action=getComments")
.success(function (data) { $scope.comments = data; });
// Removed in Angular 1.7 — global controller functions no longer resolve
function commentsController($scope, $http) { }
.success() and .error() were deprecated in 1.4 and removed in 1.6 in favour of standard promise .then(). Implicit global controller functions stopped working in 1.7 — controllers must be registered on a module. So the code fails on the last three releases of a framework that itself is unsupported.
There is also a straightforward injection bug in it:
data : "action=add&msg=" + comment.msg + "&name=" + comment.name
Any & or = a user types corrupts the request body. A comment saying "profit & loss" silently arrives truncated.
No framework needed for a form and a list.
<form id="comment-form" class="comment-form">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>" />
<div class="field">
<label for="name">Name</label>
<input type="text" id="name" name="name" required maxlength="80" />
</div>
<div class="field">
<label for="message">Message</label>
<textarea id="message" name="message" required maxlength="2000"></textarea>
</div>
<button type="submit">Post comment</button>
<p id="form-status" role="status" aria-live="polite"></p>
</form>
<ul id="comments" class="comments"></ul>
const form = document.getElementById('comment-form');
const list = document.getElementById('comments');
const status = document.getElementById('form-status');
async function loadComments() {
try {
const res = await fetch('/api/comments.php', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
render(await res.json());
} catch {
status.textContent = 'Could not load comments.';
}
}
function render(comments) {
list.replaceChildren();
for (const comment of comments) list.prepend(buildItem(comment));
}
function buildItem(comment) {
const li = document.createElement('li');
li.className = 'comment';
li.dataset.id = comment.id;
const name = document.createElement('strong');
name.textContent = comment.name; // textContent — no XSS possible
const time = document.createElement('time');
time.dateTime = comment.created_at;
time.textContent = new Date(comment.created_at).toLocaleString();
const body = document.createElement('p');
body.textContent = comment.message;
li.append(name, time, body);
return li;
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const button = form.querySelector('button');
button.disabled = true; // prevents double submission
status.textContent = 'Posting…';
try {
const res = await fetch('/api/comments.php', {
method: 'POST',
// FormData encodes correctly — no manual string concatenation.
body: new FormData(form),
headers: { Accept: 'application/json' },
});
const data = await res.json();
if (!res.ok) {
status.textContent = data.error ?? 'Could not post your comment.';
return;
}
list.prepend(buildItem(data));
form.reset();
status.textContent = 'Comment posted.';
} catch {
status.textContent = 'Network error. Please try again.';
} finally {
button.disabled = false;
}
});
loadComments();
Two things doing real work here. FormData handles encoding, so ampersands and equals signs in user input cannot corrupt the request. And textContent rather than innerHTML means a comment containing <script> renders as literal text — the XSS hole in the original disappears by construction.
<?php
declare(strict_types=1);
session_start();
header('Content-Type: application/json');
$pdo = new PDO('mysql:host=localhost;dbname=demo;charset=utf8mb4', $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$stmt = $pdo->query(
'SELECT id, name, message, created_at FROM comments ORDER BY created_at DESC LIMIT 50'
);
echo json_encode($stmt->fetchAll(), JSON_THROW_ON_ERROR);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(403);
exit(json_encode(['error' => 'Session expired. Please refresh.']));
}
$name = trim($_POST['name'] ?? '');
$message = trim($_POST['message'] ?? '');
if ($name === '' || mb_strlen($name) > 80) {
http_response_code(422);
exit(json_encode(['error' => 'Please enter a name under 80 characters.']));
}
if ($message === '' || mb_strlen($message) > 2000) {
http_response_code(422);
exit(json_encode(['error' => 'Please enter a message under 2000 characters.']));
}
$stmt = $pdo->prepare(
'INSERT INTO comments (name, message, created_at) VALUES (?, ?, NOW())'
);
$stmt->execute([$name, $message]);
$id = (int) $pdo->lastInsertId();
// Return the created row so the client can render it without a refetch.
http_response_code(201);
echo json_encode([
'id' => $id,
'name' => $name,
'message' => $message,
'created_at' => date('c'),
], JSON_THROW_ON_ERROR);
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
CREATE TABLE comments ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(80) NOT NULL, message TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_created (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
Two corrections to the original schema. It used CHARSET=latin1, which cannot store emoji or most non-Western scripts — utf8mb4 is the only sensible choice. And its column was named comments while the JavaScript sent msg, so the two never matched.
The original deleted via GET:
// Wrong — GET must not change state url : "index.php?action=delete&id=" + id
A crawler following links, or a browser prefetching, will delete your data. Worse, any site can trigger it with <img src="…action=delete&id=5">. Use DELETE or POST, with a CSRF token and an authorisation check:
if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
parse_str(file_get_contents('php://input'), $input);
if (!hash_equals($_SESSION['csrf'] ?? '', $input['csrf'] ?? '')) {
http_response_code(403);
exit(json_encode(['error' => 'Invalid token']));
}
if (!currentUserCanModerate()) {
http_response_code(403);
exit(json_encode(['error' => 'Not permitted']));
}
$pdo->prepare('DELETE FROM comments WHERE id = ?')->execute([(int) $input['id']]);
http_response_code(204);
exit;
}
You have three realistic options:
| Option | Effort | When it fits |
|---|---|---|
| Rewrite in a current framework | High | Actively developed products |
| Incremental migration | Medium, ongoing | Large apps you cannot pause |
| Extended support vendor | Paid | Buying time only |
For the incremental path, replace one route or component at a time with plain JavaScript or your target framework, keeping both on the page during the transition. Start with the pieces that touch user input or authentication, since those carry the security risk.
If you must patch existing AngularJS code, at minimum modernise the promise handling:
// Old — removed in 1.6
$http.get(url).success(fn);
// Works on 1.6+
$http.get(url).then(function (response) {
$scope.comments = response.data; // note: .data, not the raw response
});
// And register controllers explicitly — globals stopped working in 1.7
angular.module('app', []).controller('CommentsController', ['$scope', '$http', function ($scope, $http) {
// …
}]);
FormData for encoding; never concatenate request bodies by hand.textContent, not innerHTML, for user-supplied data.GET.utf8mb4, not latin1.Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitYou might also like
We ship software that scales. Let's work together.