Chapter 1 of 8. Before you write a line of PHP, decide whether you want to own the list. Then work out what owning it makes you responsible for.
Search "self hosted newsletter cpanel php mysql" and you get a recognisable genre of result. One subscribe.php. A MySQL table with two columns. A call to mail(). Then, near the bottom, a banner: this example is not production ready. The banner is honest, but it never says which parts are missing.
This chapter says which parts are missing. They are cheap to skip and expensive to retrofit. Several of them are legal, and they attach the moment the first email address lands in your table.
The system this course documents is live. It runs on cPanel shared hosting, PHP 8.2 against MySQL 8.0.45. No Composer, no autoloader, no framework: six library classes, four PHP files under the webroot, one cron script. Eleven files, 1,382 lines. Every snippet in the next eight chapters is code that is currently running, including the parts that were wrong on the first deploy and the hotfixes that corrected them. The no-Composer article covers the framework-free shape, and this course covers what sits underneath it: the security and compliance surface that every tutorial banner gestures at without naming.
Why not Mailchimp?
The call
Self-host the newsletter in plain PHP on the same cPanel account as the site. Double opt-in, hashed IPs, and every subscriber record on infrastructure already paid for.
Rejected
What it costs
Deliverability becomes your problem: SPF, DKIM and DMARC by hand, plus a warm-up period where mail-tester scores rule the roadmap. Two full chapters of this course exist because of this choice.
Revisit when
If the list outgrows shared-hosting send limits, or deliverability drops below the high nineties despite correct DNS.
What a self-hosted newsletter on cPanel actually is: PHP, MySQL and a cron
Strip the framing away and the parts list is short. You need a public POST endpoint, a database, an outbound mailer and a scheduled job. On cPanel that maps to plain PHP files under public_html/, a MySQL database, PHP's mail() handing off to the host's local MTA and a schedule on the host itself.
The schedule earns a warning, because the obvious route failed here. Cpanel::API::Cron is not installed on this cPanel build, so the Cron Jobs interface was unusable and both schedules went into the host crontab via crontab -e instead. A docblock at the top of the real cron/purge.php still reads Schedules wired in P8-9 (cPanel Cron Jobs). That is now false, and it is exactly the kind of comment that survives a hotfix because nothing compiles it. Check how your host actually accepts a cron entry before you plan around the panel.
The endpoint is a pipeline, and the order of its stages is the design. This is the docblock at the top of the real subscribe.php, in full:
/**
* POST /api/subscribe/ — public, unauthenticated.
*
* Pipeline:
* 1. Method check — POST only, else 405
* 2. Origin check — Origin header must match SITE_URL, else 403
* 3. Per-IP rate limit — Throttle::check(); 429 on exceed
* 4. Honeypot + time-trap — silently succeed if bot-shaped
* 5. Email validation — silently succeed if invalid (no oracle)
* 6. DB upsert — pending row created OR token re-issued on
* existing pending row; existing confirmed
* or unsubscribed rows are NOT mutated
* 7. Confirmation mail — sent only if a token was created/re-issued
* 8. Redirect — always to /newsletter/check-your-inbox/
* regardless of what happened above
*
* The "always same response" rule is constitution §1.5 STRIDE-I: no
* caller can tell from the HTTP behaviour whether their email was new,
* already pending, already confirmed, already unsubscribed, or invalid.
* That kills the user-enumeration oracle.
*
* Logger events emitted (visible only to the operator):
* bot_detected — honeypot, time-trap, invalid email, wrong origin
* rate_limited — 429 path
* signup — brand-new pending row inserted
* confirm_sent — confirmation email dispatched (new OR re-issue)
*
* Constitution: §1.1 A01/A02/A05/A07, §1.3 API A01, §1.2 V5.1/V7.4,
* §1.5 STRIDE (all letters), §2.1, §5.1 Tier-3, §9.1.
*/
Eight stages. Six of them exist only because the endpoint is public. The cheap ones deliberately run before the expensive ones, which is what OWASP's denial-of-service guidance asks for: perform cheap validation first, so expensive work is never spent on hostile input. A method check costs nothing; a database round-trip and an SMTP handoff cost a lot. Skip that ordering and your form becomes a free outbound mail cannon, aimed at whoever an attacker names.
Now read stage 8 again, then go and read the code, because they disagree. always to /newsletter/check-your-inbox/ regardless of what happened above is not what subscribe.php does. One branch redirects somewhere else, and it does so on purpose:
// Honeypot is silent (a human will never fill a hidden field — anything
// caught here is bot-shaped). Time-trap routes to a user-visible
// please-try-again page since legit humans CAN trip it (autocomplete
// pre-fill + fast click) and silent rejection is bad UX.
Trip the time-trap and you land on /newsletter/please-try-again/. That is a deliberate call, and the reasoning is sound: a real person whose browser autofilled the form and who clicked fast can trip a timing check, and silently pretending to sign them up would be a worse failure than telling them to try again. The security property survives intact, because the time-trap fires on timing rather than on the address. It cannot tell an attacker anything about whether a given email is on the list, which is the enumeration oracle the always-same-response rule exists to kill. The invariant is better stated over email state than stated unconditionally: nothing in the response varies with what the database knows about the address you typed.
So the docblock is stale on stage 8. It is the same defect the purge script has, in the file this chapter quoted in full, three paragraphs after using the purge script to warn you about it. That is how comments rot. Nothing compiles them, the behaviour moved, and the prose that describes the behaviour is the last thing anyone updates. Trust the code; treat every comment, including the ones in this course, as a claim awaiting a diff.
The .env holding the database credentials lives outside the webroot, at ~/captainrandom-newsletter/, a sibling of public_html/ rather than a child of it. The library classes and email templates live beside it. That split is why the first live request to this backend returned a 500 with an empty body: the public scripts had been scaffolded with require_once __DIR__ . '/../lib/Db.php', which resolves to ~/public_html/lib/, and on this deploy layout there is no lib/ there. The fix was to anchor the path at the user home instead, dirname(__DIR__, 2) . '/captainrandom-newsletter/lib/Db.php', across 14 lines of three files. Two correct decisions and one missed reconciliation between them, which is the shape most deploy bugs take.
The rules you inherit the moment you store an email address
Two separate UK regimes apply to a newsletter, and people routinely conflate them. The one that decides whether you may send the email at all is PECR, not UK GDPR.
The ICO's guidance on electronic mail marketing sets out regulation 22: you must not send marketing email to individuals unless they have specifically consented, or the soft opt-in applies. Soft opt-in is narrow. It covers someone who bought a similar product or service from you (or negotiated to buy one) and only where you gave them a simple way to opt out both at collection and in every message since. A content newsletter sent to people who have never bought anything from you cannot use it. You need consent, and the consent has to be real.
The ICO's definition of valid consent is the standard your flow has to meet: freely given, specific, informed and an unambiguous indication of the individual's wishes by clear affirmative action.
Article 7 then adds the requirement tutorials never mention. You must keep records to demonstrate consent, and withdrawing consent must be as easy as giving it.
Both halves of that are columns. One statement in confirm.php writes the consent record. It fires only when the subscriber clicks the link in the email and the resulting POST verifies the token:
UPDATE subscribers
SET status = ?,
consented_at = NOW(),
confirm_token_hash = NULL,
confirm_expires_at = NULL
WHERE id = ?
consented_at is never stamped at signup. Signup is a claim; confirmation is the evidence. The same statement destroys the token it has just verified, so the link cannot be replayed. Double opt-in is what produces the Article 7 record, and it settles a security question on the way past: OWASP's input-validation guidance points out that syntactic validation of an address tells you nothing about whether the mailbox exists or whether the person submitting it controls it. Only a single-use, time-limited token sent to that address proves it. Minting that token is chapter 2.
The compliance surface, expressed as MySQL columns
Here is the whole subscriber table. Twelve columns, and nearly every one exists to answer a question a regulator or an attacker will eventually ask.
CREATE TABLE IF NOT EXISTS subscribers (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(254) NOT NULL,
status ENUM('pending','confirmed','unsubscribed')
NOT NULL DEFAULT 'pending',
confirm_token_hash CHAR(64) NULL,
confirm_expires_at DATETIME NULL,
unsubscribe_token_hash CHAR(64) NOT NULL,
consented_at DATETIME NULL,
unsubscribed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
source VARCHAR(64) NULL,
ip_hash CHAR(64) NULL,
ua_hash CHAR(64) NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_email (email),
KEY idx_status (status),
KEY idx_confirm_expires (confirm_expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Read it as a compliance document rather than a data model. status is the state machine, and pending is where an address waits while it is still only a claim. The token columns are CHAR(64) because they hold sha256(plaintext) and never the plaintext, which lives only in the URL inside the email you send. confirm_expires_at exists because a token that never expires is a permanent liability. ip_hash and ua_hash are hashed at the server before insert because a raw IP address is an online identifier, and the ICO names IP addresses explicitly in its list of them. The full storage rules are chapter 3.
The column that hurts most to add later is unsubscribed_at, the clock for deletion. Storage limitation, per the ICO, is blunt: do not keep personal data for longer than you need it, keep a retention policy with standard periods and never keep data indefinitely just in case. Pending rows a bot created and never confirmed are a liability with a clock on them. This is the statement that deletes them:
DELETE FROM subscribers
WHERE status = 'pending'
AND confirm_expires_at IS NOT NULL
AND confirm_expires_at < (NOW() - INTERVAL 1 HOUR)
That pass runs hourly, an hour of grace past the 60-minute token expiry so a slow user mid-click is not deleted underneath themselves. The nightly pass adds unsubscribed rows older than 30 days, event-log rows older than 12 months and bot-detection rows older than 90 days. Two crontab lines enforce the whole retention policy:
0 3 * * * /usr/bin/php ~/captainrandom-newsletter/cron/purge.php
0 * * * * /usr/bin/php ~/captainrandom-newsletter/cron/purge.php --pending-only
What shared hosting gives you, and what it takes away
MySQL, cron and a mail transport are already running on the box before you write a line. On this host, cPanel's AutoDKIM had already published a DKIM key and the SPF record was already correct before any newsletter code existed, so a good deal of what looks like infrastructure work is really just wiring together services that were already there.
Privilege isolation is the part you build yourself. The app user and the cron user are two different MySQL users with two different privilege sets. Written as SQL, they are these:
GRANT SELECT, INSERT, UPDATE
ON captainrandomco_newsletter.*
TO 'captainrandomco_news'@'localhost';
GRANT SELECT, UPDATE, DELETE
ON captainrandomco_newsletter.*
TO 'captainrandomco_news_cron'@'localhost';
Do not copy that into a MySQL prompt and expect it to work. It is documentation, not the mechanism. schema.sql carries those two statements inside a comment, under a label that says the quiet part out loud: equivalent raw SQL, not executed from this file, because neither scoped user holds GRANT OPTION and only the cPanel main user can issue a grant at all. What actually applied the privileges was cPanel's own API:
uapi Mysql set_privileges_on_database \
user=captainrandomco_news \
database=captainrandomco_newsletter \
privileges='SELECT,INSERT,UPDATE'
The effect is what the SQL describes. The public endpoint cannot delete a row. The cron cannot create one. Neither user can CREATE, DROP or ALTER, so a schema change means SSH-ing in as the cPanel main user and running a migration file by hand, which is the point: schema changes are rare and should not be one-liner-automatable.
Be precise about what that buys, because it is easy to oversell, and the honest version is narrower than it first looks.
What the split bounds is a credential, not a file. Hand somebody the app user's password and nothing else, and they hold SELECT, INSERT, UPDATE on the newsletter database. Every row becomes readable. Inserting rows no longer requires going anywhere near the form, which routes around the Origin check, the honeypot, the time-trap and the 20-per-minute throttle in one move. Worse, any row can be rewritten: status = 'confirmed' and consented_at = NOW() on an address that was never sent a token. What that password cannot do is delete a row, drop the table or change its shape. That bound is real and it is worth having.
The file is a different question, and this is where a comforting summary would lie to you. There is exactly one .env. Every entry point loads the same path: subscribe.php, confirm.php, unsubscribe.php, preview-email.php and cron/purge.php all call Db::loadEnv(dirname(__DIR__, 2) . '/captainrandom-newsletter/.env'). That single file holds DB_PASS and DB_CRON_PASS side by side, and the cron user is the one with DELETE. So "the .env behind the public endpoint leaks" is not a bounded event at all: it hands over the delete-capable credential too. It also hands over HASH_SALT, which is what makes ip_hash and ua_hash pseudonymous in the first place, and INTERNAL_PREVIEW_TOKEN. Leak the file and the privilege split buys you nothing, because both sides of it are in the file.
Say that out loud rather than rounding it off. The privilege split is a good control against a leaked app credential and a compromised web process. It is not a control against a leaked .env, and splitting the secrets per role across separate files is the work it would take to make it one. Knowing which of those two you actually have is the difference between a threat model and a reassuring paragraph.
OWASP's Application Security Verification Standard is the yardstick this backend was written against, because "production ready" should be checkable. ASVS gives you stable, versioned requirement identifiers (the v5.0.0-1.2.5 form, chapter-section-requirement, pinned to a release) that you can cite against a specific control in a specific file, which is a claim of a completely different kind from a banner at the foot of a tutorial.
What the eight chapters build
The course follows one subscriber through the system, in order.
- A visitor POSTs the form
The endpoint runs its eight stages, and what comes back never varies with what the database knows: brand new, already confirmed, already unsubscribed or complete garbage all look the same from outside. Chapter 2 and chapter 6 are that endpoint: token generation, bot traps, per-IP rate limiting and the enumeration-safe response shape.
- A confirmation email arrives, and lands in the inbox
Delivery is decided before a human ever sees the message. Chapter 4 is SPF, DKIM and DMARC, the three records that determine whether the mail arrives at all. Then chapter 5 opens up the multipart MIME it is built from, hand-rolled, with a plain-text part that reproduces byte for byte the test message that scored 9.2/10 on mail-tester before any backend code existed.
- The click writes the consent record
That click is the only event in the whole system that writes
consented_at. Chapter 3 covers what you store and how: hashed identifiers, prepared statements, and why hashing an IP does not take it out of scope.
The full list is on the course index. Every chapter quotes the code that shipped, including the hotfixes it took to keep it standing.
The transferable rule
Tutorials stop at INSERT INTO subscribers because everything after it is invisible in a demo. A form that adds a row and sends an email looks finished. It looks unfinished the first time a bot floods it, or a subscriber asks for their data back, or a confirmation link from three months ago still works. Then someone asks how you proved consent.
So the rule is this. On any endpoint that accepts personal data from a stranger, the schema is the specification and the code is downstream of it. Write the columns first. Make each one answer a question you can be asked out loud. Who consented. When. To what. How do I prove it. When does this row stop existing. A column that answers none of those should not be there. A question with no column behind it is a feature you have not built yet, whatever the form appears to do when you click it.
The next chapter starts where the row does: minting the confirmation token, and why 32 bytes from a CSPRNG is the only acceptable answer.
Schema-first compliance for one of your own forms
Pick an endpoint in your own project that accepts personal data from strangers (a contact form, a waiting list, a booking form, a support widget). Apply this chapter's rule: write the columns first, make each one answer a question a regulator or an attacker could ask, then add the retention and privilege plumbing the tutorials skip.
Expected behaviour
- A schema file where every column carries a comment naming the question it answers: who consented, when, how do I prove it, when does this row stop existing
- A scheduled purge script whose retention intervals are written in SQL, with all datetime arithmetic on the database side
- Two database users with different privilege sets: the web-facing user cannot DELETE and the scheduled user cannot INSERT
- The retention periods published somewhere a user can read them, in the same numbers the purge script enforces
PROVE IT Seed an expired row, run the purge script and show the row deleted; then attempt the same DELETE as the web-facing user and show the privilege error.
The single .env behind the public endpoint leaks. What has the app-versus-cron privilege split bought you in that event?
In the crontab, what is the schedule expression for the full nightly purge pass?
The chapter closes by saying the schema is the specification and the code is downstream of it. What does that mean in practice for an endpoint that accepts personal data?
Show answer
Write the columns before the code, and make each column answer a question you can be asked out loud: who consented, when, to what, how you prove it, and when the row stops existing. A column that answers none of those should not exist, and a question with no column behind it is a feature you have not built yet, however finished the form looks when you click it.
↺ re-read: “The transferable rule”