piplet: When Program and Data Live in the Same File
WordPress' piplet stores its notes behind __halt_compiler() inside its own PHP file. What the source teaches about atomic writes, inode races, validating before parsing and optimistic concurrency — and which of it belongs in your own projects.
by Jean Pierre Kolb ·
Today I stumbled across a repository I have not been able to get out of my head since: WordPress/piplets (opens in a new tab). A single PHP file, 175,162 bytes. Inside it sits a complete wiki: PHP backend, HTTP API, HTML, CSS, JavaScript — and the notes. Not in a database next to it, not in a data/ directory. In the same file, right behind the program code.
With something like this I am rarely interested in the question "should I deploy it". I am interested in: how is it built? Which problems appear once you tear that boundary down? And which of the answers are good enough that I can reuse them in projects that have nothing to do with self-modifying PHP?
This article is my answer to those three questions. It is not a review and not a recommendation. It takes the source apart, places it in a tradition that reaches back to Perl, and distils at the end what is transferable. Where I quote numbers, I measured them on a running system; where I could not verify something, I say so.
The trick, in four lines
The repository contains two programs. The small one is called wiki-piplet-unsafe.php, is 78 lines long and exists purely to show the core. The decisive lines are numbers 3 and 4:
$raw = file_get_contents(__FILE__);
$pages = json_decode(substr($raw, __COMPILER_HALT_OFFSET__), true);The program reads itself. And at the end of the very same file:
<?php __halt_compiler();
{"welcome":{"title":"Hello, piplet","body":"This is a deliberately tiny, unsafe piplet."}}__halt_compiler() is not an ordinary function but a rule in PHP's grammar. It does not stop execution, it stops lexical analysis: the compiler stops reading the file at that point. Everything after it never becomes tokens, never becomes opcodes, never becomes anything executable. They are simply bytes at the end of a file. The PHP manual describes the purpose itself:
"Halts the execution of the compiler. This can be useful to embed data in PHP scripts, like the installation files."
So that you can reach those bytes, PHP defines a constant at compile time: __COMPILER_HALT_OFFSET__, holding the byte position where the data starts. Both have existed since PHP 5.1.0, so since 2005.
One detail I measured myself, because explanations online get it wrong regularly: the offset points at the byte immediately after the semicolon. The following newline is not skipped, it already belongs to the data. If you assume while serialising that PHP swallows "that one newline", you shift by exactly one byte — and write garbage on the next save.
Writing back is a single line in the small version:
file_put_contents(__FILE__, substr($raw, 0, __COMPILER_HALT_OFFSET__) . "\n$json\n", LOCK_EX);The program part is taken from the buffer read at request start, prepended byte for byte, and fresh JSON follows. I ran three consecutive saves: the file size stays stable across all three — 7,066 bytes in my test, though the number depends on the text you save — with exactly one \n between __halt_compiler(); and {. The executable part reproduces itself byte for byte.
That is all of it. No database handle, no migration, no schema. And in that nakedness you can immediately see why the file is called "unsafe" — more on that later.
Interim conclusion: the core of piplet is a 20-year-old PHP mechanism almost every developer has used without noticing. The mechanism is not what is new. What is new is the direction it is used in.
Where this pattern comes from — and where it ends everywhere else
Because "data behind the code" is one of the oldest ideas in the craft. I looked at how other languages solve it, and a pattern emerged that I had not had on my radar in this form.
| Language | Idiomatic equivalent | Since | Writable at runtime? |
|---|---|---|---|
| Perl | __END__ / __DATA__ plus the DATA filehandle | __END__ since Perl 4, __DATA__ since Perl 5 | no, read-only handle |
| PHP | __halt_compiler(), and phar on top of it | 5.1.0 / 5.3 | phar locked by default |
| Python | no language feature; zipapp (shebang + ZIP) | 3.5 | yes, see below |
| Go | //go:embed | 1.16 (2021) | no, explicitly read-only |
| Java | JAR = ZIP; data before it unbounded, after it only ~64 KiB | — | practically no |
| Shell | marker + tail; makeself: byte offset + dd | 1998 | no |
Perl's documentation (opens in a new tab) is unambiguous: "The filehandle is left open pointing to the line after __DATA__." A read handle. A documented way back does not exist. Go's embed package (opens in a new tab) is even clearer: "An FS is a read-only value" — and it acts at compile time anyway, so it could not write back if it wanted to. And makeself, the tool behind countless self-extracting installers, does compute a byte offset into its own file, but only reads it with dd.
Two exceptions turned up when I tried to refute this, and both are explicitly documented. Python can write back: zipfile.ZipFile(sys.argv[0], 'a') appends to an existing archive, and the manual names exactly that case — "This is meant for adding a ZIP archive to another file (such as python.exe)." The shebang prefix stays byte-identical and the .pyz keeps running. And SQLite ships a VFS for it: sqlite3 --append puts a fully writable database behind an arbitrary file, per the source comment "such as an executable". What I found nowhere is a language that provides a way back into its own __DATA__ section.
The most interesting case is PHP's own descendant. phar archives use exactly the same separator marker. The smallest possible phar stub (opens in a new tab), per the manual, is:
<?php __HALT_COMPILER();So PHP has known "program plus appended data" as an official package format since 2005. And it has locked the write channel by default, with a justification I have rarely seen in that form in a language reference:
"This option disables creation or modification of Phar archives using the
pharstream or Phar object's write support. This setting should always be enabled on production machines, as the phar extension's convenient write support could allow straightforward creation of a php-based virus when coupled with other common security vulnerabilities."
phar.readonly (opens in a new tab) defaults to 1 and cannot be switched off from inside a script, only in php.ini. piplet does not circumvent that lock, it never touches it: it does not write through the phar:// stream but with ordinary file functions.
The wiki line: TiddlyWiki and the browser's prohibition
There is a second tradition, and it is even older. TiddlyWiki, first released in September 2004, is the original self-saving single-file wiki. Wikipedia calls it "an unusual example of a practical quine" — the ability to produce a copy of its own source lies at the heart of its saving feature.
Except: TiddlyWiki cannot write itself. The browser forbids it. The project's 21-year history is a chain of detours around exactly that prohibition: in the Classic era it was ActiveX FileSystemObject in Internet Explorer, a Java applet and Mozilla's UniversalXPConnect — the last of which Firefox later removed. The core of TiddlyWiki 5 today holds 15 saver modules, among them TiddlyFox (which stopped working with Firefox 57 in 2017), HTTP PUT, GitHub/GitLab/Gitea uploads and savers for the wrapper apps AndTidWiki and TWEdit. Feather Wiki, the modern relative at around 57 kB, does the same: download, or an HTTP PUT to a server you have to provide yourself — the save button only appears if the far end returns a dav header.
That is the real point about piplet's shape: it does not solve TiddlyWiki's problem. It runs server-side, and there a process may replace its own file. The browser sandbox as an adversary disappears — in exchange it inherits a different one: hardening policies.
And the PHP single-file apps?
Here I wanted certainty, because the comparison "that's just Adminer" is the obvious one. I downloaded the sources instead of trusting project descriptions.
| Project | Single file? | Writes into its own file? | Where does the data go? |
|---|---|---|---|
| Adminer 6.0.1 | yes, 519,492 B | no | into the managed database |
| tinyfilemanager 2.6 | yes | yes — configuration only | own file or config.php |
| WonderCMS 3.6.0 | almost | yes — self-update only | database.js |
| Bludit 3.22 | no | no | metadata in .php files with a JSON body, page text in index.txt |
| Pico 2.1.4 | no | no — not a single write call | content/*.md |
| antonmedv/wiki | yes | no | db/wiki.db (SQLite) |
Adminer is the most important negative finding. In the shipped file file_put_contents appears zero times, there is no write to __FILE__ and no __halt_compiler. Adminer is a single-file delivery, not a self-saving program.
The closest match is tinyfilemanager: line 3 of the shipped file is a $CONFIG = '{"lang":"en",…}';, and a method writes exactly that line back into __FILE__ as soon as you change language or theme. But those are settings, not content — and as soon as a config.php sits next to it, it moves there. WonderCMS also overwrites its index.php, but with new code during self-update; the data goes to database.js.
The most interesting borderline case is Bludit: its database files are PHP files. They start with <?php defined('BLUDIT') or die('Bludit CMS.'); ?>, with JSON underneath. But that prologue is direct-access protection, not a program — and the program file itself stays untouched.
Interim conclusion: "data behind the code" is almost everywhere a read pattern — for installers, archives, embedded assets. The way back is rare and is a language feature nowhere; where it exists, it is a library or tooling route, as with Python's zipfile and SQLite's appendvfs. And of the six production single-file and flat-file PHP projects in the table, none writes its payload into its own program file. piplet stands at the end of that line, not in the middle of it. I found no older, named PHP project that stores content behind __halt_compiler() in its own file — which does not mean none exists, only that I could not find one.
The large edition: 3,584 lines
Next to the 78-line demo sits wiki-piplet.php. Same idea, built out:
wiki-piplet-unsafe.php | wiki-piplet.php | |
|---|---|---|
| Lines | 78 (29 of them PHP) | 3,584 |
| Size | 7,209 B | 175,162 B |
| Split | almost all markup | PHP 1,541, JS 1,597, CSS 362, HTML 80 lines (rest: data trailer) |
| Protection | none | Basic auth (mandatory), CSRF, CSP with nonce, Fetch Metadata |
| Write path | file_put_contents(__FILE__) | temp file + fsync + rename |
| Tests | 64 assertions | 433 assertions |
The layout of the large file:
wiki-piplet.php
├── PHP persistence and HTTP API
├── HTML, editable CSS, browser UI
├── __halt_compiler();
├── PIPLET-DATA/2
└── { generation, versioned JSON notes, appearance }Before I go into the code, a word on provenance — it belongs to the picture.
Matt Mullenweg presented piplets on 19 August 2026 in the closing fireside chat of WordCamp US in Phoenix, in conversation with Robert Jacobi. The official recap on wordpress.org, written by Nicholas Garofalo, sums it up in exactly one paragraph:
"After a detour through why AI models now prefer simple, self-contained HTML files, the argument became tangible with WordPress Piplets, a 2007 idea built around a single self-modifying PHP file that stores its own data, with no database and no third-party packages. One file can hold up to 25 megabytes, about four million words, and load in about 50 milliseconds. For Mullenweg, it extends the experiments that took WordPress from MySQL to SQLite to Playground, and he floated the idea of a WordPress.org directory where people could publish, fork, and remix."
And the only quote the recap attributes to Mullenweg verbatim:
"I want WordPress to be known for simplicity, not just complexity." — Matt Mullenweg
Three observations I made while checking, all useful when reading the code.
First: the number in the recap and the number in the code diverge. The source caps the file at PIPLET_MAX_FILE_BYTES = 8 * 1024 * 1024, so 8 MiB, and notes at 2,000. The README says it just as plainly: "The configured file ceiling is 8 MiB; request JSON is capped at 5 MiB, stored notes at 2,000, and tag references at 24,000." That is roughly a third of the 25 MB quoted. And the value is not a later hardening artefact — it is already in the very first commit, then named PHPLET_MAX_FILE_BYTES, a good three days before the stage. Neither "25" nor "million" nor "millisecond" appears in the README even once. And for attribution this matters: the three numbers sit in Garofalo's prose in the recap, not inside a marked Mullenweg quote. If you quote them, put the 8 MiB next to them.
Second: the commit history is its own story. Fourteen commits, all with author and committer Codex <codex@openai.com> — none of them cryptographically signed, so the attribution rests on the Git author field. No human commit, no Co-authored-by. Commit f928aca is called "Rename: phplet -> piplet" and carries the timestamp 2026-08-19, 21:17 UTC — that is 1 hour and 43 minutes before the session in which the name was mentioned. Before that, the file, the README title and the constant prefix all read phplet — with one telling exception: the format marker in the data already read PIPLET-DATA/1 in the very first commit. The name existed three days before the rename, just not as the project name. And the repository itself was only created on 21 August at 03:31 UTC, roughly 28 hours after the announcement; the first push followed 69 seconds later. That is the pattern of a one-off import.
Third: I could not substantiate the 2007 origin. I went through the blog search on ma.tt, the wp-hackers mailing list archive for July 2007 and the usual search routes — no occurrence of "piplet" or "phplet" before August 2026. The attribution "2007 idea" rests solely on Mullenweg's own spoken statement, relayed in the official recap. A recording does exist: WordPress published the livestream as Closing Keynote — Fireside Chat with Matt Mullenweg and Robert Jacobi (opens in a new tab), 1:07:48 long, with auto-generated English captions. There is no edited transcript, and I did not evaluate the recording — if you need the exact wording on the 25 MB, that is where it is. The missing 2007 evidence is not a refutation, just a gap you should know about before passing the year along.
Interim conclusion: piplet was, per its commit metadata, written in a few days by an AI agent, published a good day after the announcement, and has carried its project name since 103 minutes before the talk. None of that says anything about the quality of the code — and as you will see, that quality is remarkable in several places. It says something about the half-life of conference numbers.
What a save actually does
This is where it gets interesting, because this part is instructive independently of PHP, wikis and WordPress.
The first surprise: piplet does not overwrite itself at all. The phrase "self-modifying file" describes the result, not the route. In the entire program file_put_contents appears zero times. Both write paths open the live file read-only (@fopen($path, 'rb')), the only write handle points at a temporary file, and the canonical path is changed by exactly one rename() call.
Two consequences follow that are easy to miss:
- Reading needs no lock. Anyone who can open the file holds a descriptor on an inode that is never mutated again. They are guaranteed to see a complete old or a complete new file — never half of one.
- The PHP process needs no write permission on the file, but on the directory. That is exactly what the UI checks:
is_readable($path) && is_writable(dirname($path)).
The sequence in its eleven core steps, in the order of the code:
- Check whether
fsync()exists at all — if not, HTTP 503, before anything is locked. fopen($path, 'rb'), thenflock(LOCK_EX | LOCK_NB). If the lock blocks, sleep 5–20 ms and retry.- Compare
fstat()of the descriptor againststat()of the path. If they differ, release and start over (more on this in a moment). - Reject hard links:
nlink !== 1→ abort. - Read the file, decode the trailer, apply the mutation, increment the document revision.
$prefix = substr($raw, 0, __COMPILER_HALT_OFFSET__);- Compute the exact output size before any file exists.
- Create a private temp file next to it, fill it in 64 KiB chunks,
fflush+fsync. - Adopt the live file's permission bits,
fsyncagain. - Check one last time that the target path is still the same inode.
rename($temp, $path).
Step 6 is my favourite line in the whole file. The offset comes from the running compilation unit — PHP baked it in as a literal while compiling. The bytes come from the file just read under lock. There is no template, no regeneration, no strpos('__halt_compiler'), nothing that data could influence. The prefix is copied, never produced.
The inode retry: why flock(__FILE__) is wrong
This is the point where I had to stop and reread. The comment in the code names it itself:
"
rename()swaps inodes, so locking the first file we open is not enough: a waiter may have opened the old inode. We lock, compare the descriptor's device/inode with the current path, and retry until we own the live file."
The sequence it hangs on:
- A opens the file → descriptor on inode 1,
flocksucceeds. - B opens the same file → also inode 1,
flockblocks, B waits. - A writes its temp file and calls
rename(). The path now points at inode 2. Inode 1 is unlinked but stays alive because B holds it open. - A releases the lock.
And here naive and correct implementations part ways. Naively, B now gets the lock — on an orphaned file. flock on an unlinked inode succeeds perfectly well and excludes nobody, because the next writer locks inode 2. B reads the state before A, applies its change and renames its result over the path. A's save is gone without a trace — and the mutual exclusion everyone is relying on simply does not exist.
The Linux man page says precisely that: "Locks created by flock() are associated with an open file description." The lock hangs on the inode, not on the path name. After a rename() you are locking the wrong thing.
The fix is unspectacular and that is what makes it good:
$lockedStat = fstat($handle);
clearstatcache(true, $path);
if (!piplet_same_inode($lockedStat, @stat($path))) {
@flock($handle, LOCK_UN); @fclose($handle); $handle = null;
usleep(random_int(5000, 20000));
continue;
}Compare dev and ino together, start over on a mismatch. The clearstatcache() before it is mandatory, not decoration: PHP caches stat results per request, and an uncleared cache would never see the swap — a completely silent failure.
Two more subtleties. The comparison is ABA-free, because a filesystem may not reuse an inode number while a process holds a descriptor on it. Comparing two stat(path) calls at different times would not have that property. And the backoff is randomised with random_int(5000, 20000) — against lockstep when several writers wait at once.
All retries share a single monotonic deadline of two seconds (hrtime(true) + 2000000000). When it expires, you get HTTP 503 with Retry-After: 1. Worth being precise about: that deadline bounds only the retries. Once the lock is held, reading, mutating and the entire write run with no time limit at all. Two seconds are not a budget for the request.
Compute the size before a file exists
piplet_json_encoded_length() reproduces the JSON grammar without whitespace: 2 for the bracket pair, +1 per comma, for objects the quoted key plus 1 for the colon, scalars encoded individually with only their length kept. The docblock says why: "Exact length under PIPLET_JSON_FLAGS, without allocating the full JSON string."
Three reasons, all visible in the code:
- No artefact on oversize. The 413 throw sits before the temp file is created. An oversized save never produces a file that would need cleaning up.
- Memory protection. On oversize,
json_encodeis never called on the whole document — peak usage stays small instead of allocating several MiB while the old document is still in memory. - Exact byte budget. The cap is written overflow-safe:
$jsonLength > MAX - $fixedrather than$fixed + $jsonLength > MAX.
And then comes the line that makes the whole approach viable:
if (strlen($json) !== $jsonLength) {
throw new RuntimeException('Snapshot size projection failed.');
}A self-audit. The model — the hand-written length calculation — is checked against reality after the real json_encode. Should PHP's encoder ever deviate, because escaping semantics change or a type is handled wrongly, the save aborts instead of writing a file that exceeds the cap. An optimisation that checks itself.
The temp file
tempnam() has a trap I have overlooked myself once: if the given directory is not writable, the function silently falls back to sys_get_temp_dir(). But a temp file in /tmp may live on a different filesystem — and rename() is only atomic within one filesystem. Worse, PHP's rename() falls back internally to copy-plus-delete on EXDEV and still reports success. You lose atomicity without seeing an error.
piplet catches this before a single byte is written:
$created = @tempnam($directory, '.piplet-tmp-');
if (!is_string($created) || realpath(dirname($created)) !== realpath($directory)) {
if (is_string($created)) { @unlink($created); }
throw new RuntimeException('Cannot create a snapshot beside the piplet.');
}The name then gets 16 hex characters from random_bytes(8) and the extension .php. That extension is deliberate: the file briefly sits in a directory a web server serves. Without .php it would be delivered as plain text — source and every note. With .php it is executed, and then the guard at the very top of the file kicks in, before any class definition:
// A half-written temporary copy must never behave as the live application.
if (str_contains(basename(__FILE__), '.piplet-tmp-')) {
http_response_code(503);
exit('Save in progress.');
}An artefact that recognises itself. That is the pattern I noted down.
On creation piplet checks four properties: regular file, no group/other bits, nlink === 1, and fstat(fd) against lstat(path) — the last of which rules out a swapped-in symlink, because lstat follows no link. Two of them, inode identity and nlink, are checked again after the write and once more after the chmod.
Even the chmod(0600) right after creation has a non-obvious reason, explained in the comment: it repairs rather than narrows. tempnam() creates with 0600, but the umask applies — under umask 0777 you get mode 0000, and the owner could no longer open the file through its path. The target permissions are only set once content and fsync are done: "it never widens an exposed group/other-readable interval."
What is guaranteed — and what is not
The cleanup path in finally deletes only if lstat still shows the same inode and nlink === 1. If the path now points elsewhere, nothing is deleted, only logged: 'piplet left an unrecognized temporary path untouched'. A recursive delete that goes purely by path name is a footgun — here it is cleanly avoided.
What a hard kill leaves behind is tested and documented: if the process is SIGKILLed between fsync and rename, the canonical file is bit-for-bit unchanged, and next to it sits exactly one 0600 orphan — which is not a fragment but a complete, valid piplet, merely unpublished.
And now the gap the README names itself, which applies to anyone rebuilding this pattern. The authoritative reference for safe replacement is Jeff Moyer's LWN article "Ensuring data reaches disk". It lists five steps:
- "create a new temp file (on the same file system!)"
- "write data to the temp file"
- "fsync() the temp file"
- "rename the temp file to the appropriate name"
- "fsync() the containing directory"
piplet does steps 1 through 4. Step 5 is missing. fsync on the file secures content and metadata of that inode — but not the directory entry pointing at it. If power fails after the rename but before the directory block is written back, the last save can be lost. The result is still never torn: either completely old or completely new.
Interestingly, the common justification — "portable PHP cannot fsync directories" — is not quite right. I measured it: fopen($dir, 'r') followed by fsync($handle) returns bool(true), and strace shows the real syscall:
openat(AT_FDCWD, "/tmp/dsynctest", O_RDONLY) = 4
fsync(4) = 0So on POSIX systems it works. Not on Windows, and PHP's documentation never mentions the case — which makes the caution understandable. But if you rebuild the pattern in a Linux-only context, you can have step five.
Also not guaranteed, because rename() publishes a new inode: ACLs, extended attributes, setuid/setgid/sticky bits (the mask is & 0777) and ownership. A piplet deployed as root:root belongs to the PHP process user after the first save.
Interim conclusion: the write path is the strongest part of the program, and it has nothing to do with wikis. Four interlocking ideas carry it: never write in place; validate the lock against inode identity rather than the path name; know the cost before you incur it; and build every intermediate artefact so it recognises itself as one.
Takeaway: atomic writes, in any language
The pattern is language-independent and you need it more often than you think — every time a config file, a cache index, an export or a state document is replaced in production:
- Create the temp file in the same directory (not in the system temp — otherwise you cross a filesystem boundary).
- Write it fully, handling short writes in a loop.
fsyncthe file.- Set permissions,
fsyncagain if needed. renameover the target.- If possible:
fsyncthe directory.
In Node.js that is fs.writeFileSync into a temp file, fs.fsyncSync(fd), then fs.renameSync. In Python os.fsync(f.fileno()) and os.replace(). What you should do in no language is the obvious one-liner: file_put_contents($f, $data, LOCK_EX) looks safe but is not. I looked at what actually happens:
openat(AT_FDCWD, "/tmp/lockx/t.txt", O_WRONLY|O_CREAT, 0666) = 4
flock(4, LOCK_EX) = 0
ftruncate(4, 0) = 0Credit where it is due: the frequently claimed "truncate before lock" race does not exist in current PHP — the lock comes before the truncation. It is still not atomic, for two other reasons. First, the lock is advisory: any reader that does not call flock — and that is practically every include, every file_get_contents, every web server — sees the window between ftruncate() and the final write(). In that window the file is empty or half written. Second, there is no all-or-nothing promise: if the process dies in between, a truncated file remains. For a program that writes its own source, that means a parse error and total loss.
Validate before you parse
The second idea I am taking away concerns something most of us treat far too casually: json_decode.
piplet runs three of its own byte scanners before the parser sees the embedded data. All three work on the raw string without allocating. Incoming requests get two of them — the structure budget and the member names; the number scanner guards the trailer only. The comment above the first names the division of labour precisely:
/** Cheap allocation guard; json_decode remains the JSON grammar authority. */First: the structure budget
piplet_json_within_budget() counts three quantities during a character-by-character scan — structural characters, opened containers, nesting depth — while correctly skipping strings and escapes.
Why? Because json_decode's depth parameter bounds only nesting, not quantity. A body of [1,1,1,…] has depth 2 and looks entirely harmless. I measured it:
| Value | |
|---|---|
Payload [1,1,…] at the request limit | 5,242,879 bytes, 2,621,439 elements |
json_decode() memory increase | 91 MiB |
| The pre-scan | false in 0.05 ms, no increase |
With the memory_limit=128M the README requires, this particular body still fits — the peak lands around 105 MiB. Swap the ones for [1] and the same 5 MiB hold 1.3 million nested arrays: then the request dies on a fatal error instead of a clean HTTP 413. That is exactly the kind of payload the pre-scan catches before anything is allocated.
The container counter is the genuinely interesting quantity here: it is cumulative, not maximal. 8,192 flat objects side by side are bounded just as 8,192 nested ones are.
Second: duplicate member names
json_decode('{"a":1,"a":2}') throws no error. The last value wins, the first disappears silently. Injecting a record with {"version":"<real>","version":"<forged>"} lets a validation and the later use drift apart.
The scanner keeps a small stack and checks every member name for uniqueness. The trick is that it decodes the name instead of comparing bytes:
$member = json_decode(substr($json, $index, $end - $index + 1), false, 2, JSON_THROW_ON_ERROR);
$lookup = "\0" . $member; // Prevent numeric-string conversion by PHP arrays.
if (isset($stack[$slot][$lookup])) return false;Why that matters shows up in one line of my measurement series: in {"a":1,"\u0061":2} two different byte sequences denote the same member name. A byte comparison would have waved it through; json_decode collapses it. And the "\0" prefix is no decoration: PHP arrays cast decimal string keys to integers. array_keys(get_object_vars(json_decode('{"123":"x"}'))) returns [int(123)]. Precisely for that reason — and only for it — piplet's ID rule excludes pure digit strings (!ctype_digit($value)).
Third: lossless numbers
The third scanner isolates every number outside strings and checks a round trip against its own encoder flags:
if (json_encode($decoded, PIPLET_JSON_FLAGS) !== $number) return false;Decode, re-encode, compare byte for byte. Whatever does not come back identical is rejected. An extract from the measurement:
| Number | Lossless? | Reason |
|---|---|---|
1 | yes | — |
1.0 | yes | only thanks to JSON_PRESERVE_ZERO_FRACTION |
0.30000000000000004 | yes | float round trip holds |
9007199254740993 | yes | PHP integer, exact |
1e2 | no | becomes 100.0 |
-0 | no | becomes 0 |
12345678901234567890 | no | becomes 1.2345678901234567e+19 |
1e999 | no | becomes INF, json_encode throws |
The reason is not pedantry but the architecture: every save reads the whole document, decodes it and re-encodes it completely. Any number PHP does not reproduce identically would be silently rewritten on the next foreign save — including in fields piplet does not know about, since unknown keys survive the mutation. Without the check, piplet would have documents it can read but never write again.
The calibration is noteworthy: it checks PHP's round trip, not JavaScript's. 9007199254740993 passes, even though the browser would lose the number. For the fields that actually travel to the browser there is a separate limit: PIPLET_MAX_REVISION = 9007199254740991 — exactly Number.MAX_SAFE_INTEGER.
Interim conclusion: together the three scanners are under 130 lines and cover three classes a parser cannot cover in principle: cost before allocation, silent collapse semantics and silent precision loss. That is the figure of thought behind it — cheap, allocation-free pre-checks; the parser still owns the grammar. Transferable to every place where you hand foreign data to a parser: XML, YAML, CSV, image formats.
Two editors, no database
The third idea addresses a problem you would normally have a database for: what happens when two people write at the same time?
piplet layers three quantities:
| Quantity | Type | Role |
|---|---|---|
generation | 32 hex characters (128 bit) | identity of the file's lineage |
version | 32 hex characters, per record | identity of this revision of this record |
revision | integer, document-wide plus per record | display counter and ordering relation |
Every mutation sends all three as preconditions; the two hex values are compared with hash_equals(), the revision as an integer with !==. And here sits the decision that makes the whole model usable: the document revision is never a precondition. What is compared is always the affected record's version. That is why the promise "two editors can safely change different notes" holds — every save bumps the global counter, but that does not invalidate anyone else's preconditions.
If one of the three base values is missing, the server answers not with 422 but with HTTP 428 Precondition Required. That is the status code it exists for, and I almost never see it in the wild.
The ABA problem
Classic shape: a second client deletes note foo and recreates it under the same name. It looks like before. The first client still holds id=foo and would overwrite blindly.
The slug really is reused — after deletion it is free again. The case is detected via the version, not the ID. A 128-bit random value per revision cannot return through delete-and-recreate. The sequence, played through against a working copy:
create#1 => OK id=idem revision=8 version=cee493c2…
delete => OK docRev=9
recreate same title => OK id=idem revision=10 version=faddd3dd… (same slug!)
old client saves => 409 This note changed after you opened it.The revision alone would notice here too (10 ≠ 8) — but not after a restore from backup. That is what the generation is for. And the README is honest about the limit:
"They cannot detect an exact rollback to the same generation and versions a client already loaded; that requires trusted state outside this one file, such as an append-only log or database."
So against an exact rollback the remedy is: a database. That is a remarkably open sentence for a project whose selling point is "no database".
Idempotency without state
The other classic: the client sends "create a new note", the response is lost, the client retries. Without precautions you now have two notes.
Every editor generates a createToken when it opens — 128 bit from crypto.getRandomValues, stable across retries and stored alongside the draft in sessionStorage. Server-side there are three outcomes:
create#1 => OK id=idem revision=8 docRev=8
create#2 identical retry => OK id=idem revision=8 docRev=8 (unchanged!)
create#3 same token, different text => 409 This new note was already saved with different content.The second request returns 200 with the same note and an unchanged document revision. Not a single byte is written: a dedicated return type short-circuits before the increment and before persisting. The third case is the genuinely interesting design decision — with the same token and differing content, piplet neither silently creates a second note nor overwrites; it returns 409 with the existing note and lets the human decide.
And the token stays attached to the note for life. The dedupe window is therefore unbounded as long as the note exists — not time-based, as idempotency keys in APIs usually are.
Interim conclusion: optimistic concurrency across three levels — lineage, record revision, display counter — replaces a transaction here. That is not an exotic construct but exactly what an HTTP ETag and If-Match do, only per record instead of per resource. If you are building an API where two clients can edit the same object: a random version per revision beats a timestamp, and a stable idempotency key belongs to client state, not to the request.
Everything in the browser, everything from one file
The largest single block of the file is not the PHP but the JavaScript: 1,597 lines, 81 KB. There is no build, no module, no framework — one IIFE, 14 let variables as a single block of state, no diffing, no router.
Three details I found instructive.
The boot transport. The entire dataset travels inline with the first response. There are no read endpoints, only three write actions. And the data enters the page like this:
<script type="application/octet-stream" id="piplet-state" nonce="…">…base64…</script>Two properties interlock. type="application/octet-stream" is not a JavaScript MIME type — the browser parses the content as a data block, executes nothing, but exposes it through .textContent. And base64 contains no <. The alphabet is A–Z a–z 0–9 + / =. That makes it mathematically impossible, regardless of note content, to form </script> or any other HTML end tag out of the boot state. Not "unlikely" — impossible. That is a far stronger statement than any escaping.
Note text never reaches the rendered HTML. No note title, no body, no tag appears in the server-generated markup; the <title> is static. The only user input that shows up there at all is the custom CSS — and it travels as a JSON string with JSON_HEX_TAG and its siblings into a pure textContent assignment. In the client everything goes through a helper that only ever sets node.textContent. There is exactly one innerHTML access in the whole program, and both callers pass literal SVG paths. eval, new Function, insertAdjacentHTML and document.write do not appear.
The CSP is correspondingly terse:
default-src 'none'; style-src 'nonce-…'; script-src 'nonce-…'; connect-src 'self';
img-src data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'img-src data: — no 'self'. Even the app's own image files would be blocked. Side effect: the classic CSS exfiltration through background: url(https://…) in attribute selectors dies with it. And because the custom CSS feature is deliberately unbounded (32 KiB of complete stylesheet, no parser, no selector allowlist), that is exactly the containment: CSS can wreck the interface, but it cannot load anything and cannot become script. For the case where you do wreck your interface there is ?safe=1 — the parameter changes nothing about headers or permissions; it blanks the custom CSS on both server and client and shows a notice with a way back.
The draft rescuer. Around 250 lines exist solely so that text the server has never seen survives. What convinced me is not the mechanism but the stance: sessionStorage is treated as hostile input. Every recovered draft passes a structure budget of its own (256 structural characters, depth 8 — the JS mirror of the server scanner), schema validation, a key-shape check, a Unicode repair for lone surrogates, and a read-back verification:
if (raw.length > 512 * 1024 || !sessionWrite(source.recoveryKey, raw)
|| sessionRead(source.recoveryKey) !== raw) {
source.recoveryWarning = 'This browser could not store the latest draft. …';
return false;The success of setItem is not believed; the value is read back and compared. That catches browsers which silently swallow or truncate.
And the consequence is thought through to the end: if the draft cannot be secured, the application refuses the state change instead of carrying on optimistically. The editor stays open and says why. Fittingly, beforeunload is registered only in exactly that case — a normal editing session never shows a "leave page?" dialog. That is the inverse of what most applications do, and I think it is right: if the dialog is an exception, users take it seriously.
Interim conclusion: when you need to get data from server into page, base64 in an inert element is more robust than any escaping, because it excludes the attack class through the alphabet rather than a filter rule. And: treat your own client storage as foreign input. JSON.parse(localStorage.getItem(…)) followed by trust is where many SPAs are surprisingly careless.
How you even test something like this
The test suite is 3,614 lines and has no dependencies. No framework, no autoloader, no class hierarchy. The complete assertion library is eight lines:
function check(bool $condition, string $message): void
{
global $assertions;
$assertions++;
if (!$condition) throw new RuntimeException($message);
}I ran it, on PHP 8.4.24:
ok — 433 assertions; source file untouched; 7+ MiB cycle 10.86s (worker peak 27.6 MiB)Those 433 are the count of a run with the browser regressions. My first attempt did fail — the Snap Chromium under WSL got stuck on its mount-namespace construction — but the second went through. When the browser really is missing, the suite says so itself and counts differently: you get skip — Chrome unavailable; dynamic browser regressions were not run above the summary and 436 assertions below it. The README is strict here — "a skip is not evidence that the browser boundary passed" — and for release validation there is PIPLET_REQUIRE_CHROME=1, which turns a missing browser into a failure.
What is interesting is how the hard cases are made testable at all. The consistent method is source instrumentation of a throwaway copy, never mocking:
- Create an isolated copy.
- Define an exact, multi-line needle.
check(substr_count($source, $needle) === 1, 'Could not locate …')— exactly one hit, or the test aborts.- Insert an environment-driven checkpoint via
str_replace.
Step 3 is the clever part: if the application is refactored and the needle disappears or doubles, the test fails loudly instead of quietly doing nothing.
That makes things provable that would otherwise only be reproducible by chance:
- The inode race, deterministically. A file-based barrier right after the
fopen: the child writes<barrier>.opened, the runner checks the inode number, replaces the file, writes<barrier>.release. A.passedmarker makes the barrier one-shot, so the retry pass does not block again, and the injected wait loop has its own deadline and throws. - Crash consistency with a real SIGKILL. The checkpoint signals and deliberately hangs (
while (true) usleep(10000);), thenproc_terminate($process, 9). Nofinallyruns — that is the entire point. Afterwards: canonical file unchanged by SHA-256, exactly one orphan at mode 0600, and the orphan is executed and must answerSave in progress. - Partial writes without an OS fault injector. A custom stream wrapper whose
stream_writenever accepts more than 7 bytes. 120,000 bytes pushed through, result must be byte-identical. - Missing
fsyncviaphp -d disable_functions=fsync— expecting an abort with an unchanged file hash. - Lost responses with no network fault at all: send the same payload twice, then compare not just the content but the inode number — proof that no
renamehappened.
And the harness tests its own primitives before using them: that the command deadline holds, that the worker refuses /etc/passwd, that the server-readiness check does not accept a foreign process on the same port, that 1 MiB of worker output arrives untruncated — and that the runner itself returns 404 under a web SAPI.
Interim conclusion: 280 check() sites produce 433 assertions for 3,584 lines of application code. For comparison: 64 assertions for the 78-line demo. The size of the two suites is the most precise description available of what the small edition's missing properties cost.
What operating it requires
It is worth looking closely here — not as a verdict, but because the conditions themselves are instructive.
PHP 8.1 is not an arbitrary floor. fsync() only exists in PHP as of 8.1 (RFC "fsync_function", accepted 30 votes to 1). And piplet makes saving hard-dependent on it:
if (!function_exists('fsync')) {
throw new PipletHttpError(503, 'Saving is unavailable because file synchronization is disabled.');
}The second requirement — 64 bit — has an independent reason: hrtime(true) returns a float instead of an integer on 32 bit, and PIPLET_MAX_REVISION is 2⁵³−1 and not representable there. The code checks that separately.
Deployment is not "upload a file". The README demands a dedicated HTTPS origin containing only index.php, PIPLET_PASSWORD in the worker environment, every other path denied at the web server, and a backend reachable only through the TLS proxy. The requirement list for the proxy runs over ten lines: at most 5 MiB body, a 4 KiB request target, at most 64 headers, rejection of conflicting Content-Length/Transfer-Encoding combinations, rate limiting of failed authentication, Fetch Metadata checks. Unsupported are NFS/SMB, multiple hosts, serverless filesystems, hard-linked aliases and Windows — though none of that is enforced in code, there is no filesystem type check.
The password is mandatory, without exception, including on loopback. I verified that by absence: the complete list of $_SERVER keys read anywhere in the file has twelve entries — no REMOTE_ADDR, no HTTP_HOST. With no password there is HTTP 403 in plain text, deliberately without WWW-Authenticate, because a login dialog that can never succeed would be misleading. And the string "forward" appears not once in the entire program — X-Forwarded-Proto and Forwarded are not merely ignored, they are never even mentioned.
The frame a web-writable PHP file sits in. These rules exist regardless of how clean the code is. The Apache Software Foundation's HTTPD wiki (opens in a new tab) puts it most directly:
"Read access only. The web server user should not own, or be able to write to, its configuration files or content."
Germany's BSI requires, as a baseline in IT-Grundschutz APP.3.2.A1 (2023 edition), that all unnecessary write permissions be removed from the web server service, and in A2 that scripts and configuration files be protected against unauthorised reading and modification. And PCI DSS 11.5.2 (v4.0.1) requires at least weekly file comparisons on "critical files", defined as files that normally do not change but whose modification could indicate a compromise.
Precision matters to me here: none of these sources contains a sentence banning self-modifying code by name. And the WordPress hardening handbook is more nuanced than it is usually quoted — it explicitly permits write access by the web server process for /wp-content/, just not for core code. What can be inferred, and this is my inference rather than a source statement: change detection on this one file can no longer distinguish "someone saved a note" from "someone injected code". It is the same event.
The README, incidentally, anticipates the objection itself:
"A web-writable PHP file is intentionally unusual. Keep backups and do not deploy it where policy or hardening rules forbid self-modifying code."
And then there is OPcache. This is where I learned the most, because it applies without piplet too. __COMPILER_HALT_OFFSET__ is baked into the opcodes as a literal at compile time. As long as only the data behind the marker changes, the prefix stays the same length and the cached offset remains correct — which is why ordinary saving is harmless, and why piplet never calls opcache_invalidate(). But if the length of the code prefix changes, the cached offset points into nothing.
I reproduced the failure path. A piplet-style file, included twice in the same request, rewritten in between with a longer prefix:
1st include: off=144 data='PAYLOAD_ONE'
file rewritten. new size=189
2nd include: off=144 data='), $o)];\n__halt_compiler();PAYLOAD_TWO_LONGER'
TRUE offset on disk = 171, true payload = 'PAYLOAD_TWO_LONGER'The second include reads source code as payload. Three findings you cannot guess:
- Without OPcache it does not happen. It is purely a cache effect.
opcache.revalidate_freq=0does not help. It does make OPcache re-check the file within the same request — butstat()only returns whole seconds, and two writes inside the same second look identical to the cache.- The default
opcache.file_update_protection=2hides the bug, because a freshly written file is not cached at all. It only bites once the file is older than two seconds — so in production, not in a quick test.
And invalidation itself has a trap I did not know:
| Call | Return | Result |
|---|---|---|
| (none) | — | corrupt |
opcache_invalidate($f, false) | true | corrupt despite true |
opcache_invalidate($f, true) | true | correct |
The non-forced variant reports success and does nothing. The cause is mtime resolution: PHP's stat() returns whole seconds — two writes one millisecond apart share an mtime, so the file does not count as "newer". After any code change to a file that is written at runtime, force = true is mandatory. That holds for every template-cache and codegen scenario, not just this one.
For fairness, the comparison: frameworks write code at runtime all the time. Blade compiles templates to PHP, Symfony generates its container, WordPress has updated itself since 2013. The technical differences are nameable and all four documented: those artefacts live outside the document root; they are deterministically reconstructible from source files, so deleting them costs performance and not data; they can be warmed at build time, making read-only operation possible; and WordPress auto-updates only write directly when the PHP process itself owns the core files — get_filesystem_method() compares fileowner(__FILE__) with the owner of a freshly created temp file; if that does not match, WordPress falls back to FTP or SSH. With piplet the written file is the one requested by URL, it is the only copy of the content, and writing is normal operation.
Interim conclusion: the simplicity is in the artefact, not in the operation. Copying one file is simple; building the origin, the proxy, the permissions, the backups and the OPcache discipline around it is not. That is not a weakness of the implementation — it is the price of code and data sharing an inode.
What of this fits into your own projects
Here is the harvest. Nine patterns that have nothing to do with self-modifying PHP, each with the question of when they pay off and when they are overkill.
1. Temp file + fsync + rename instead of writing in place. Worth it for every file replaced during operation whose half-state does damage: configuration, caches, exports, state documents, generated assets. Overkill for append-only logs, where a truncated last entry is survivable.
2. Validate locks against identity, not against the path. As soon as anything in the system replaces via rename, a lock on the first opened descriptor is worthless. Compare fstat against stat and retry. Overkill if only one process ever writes — but you rarely know that for certain.
3. Randomised backoff with a shared, monotonic deadline. hrtime(), not time() — NTP jumps and daylight saving must not shift a deadline. And compute the deadline once before the loop instead of per attempt, otherwise it is not one.
4. Compute the cost before you incur it — and audit the model. The size projection with a subsequent comparison against the real result is the most transferable detail in the whole program. Worth it wherever a hard ceiling exists and the expensive path already costs resources. Overkill for soft limits, where aborting after the fact is fine.
5. Cheap, allocation-free pre-checks before the parser. The parser stays the authority on grammar; the scanner only answers "am I allowed to touch this at all". Worth it at every boundary where foreign data enters a parser. Overkill for data from your own build.
6. Optimistic concurrency with a random version per revision. Timestamps collide, counters return after a restore, a 128-bit random value does not. Together with a stable idempotency key on the client side, that covers the two most common multi-user failures. Overkill in single-writer systems.
7. Artefacts that recognise themselves. str_contains(basename(__FILE__), '.piplet-tmp-') turns any accidentally executed intermediate copy into a 503 instead of a second application instance. Worth it for every temporary artefact in an executable path. Overkill when the artefact structurally lives outside — then location is the better answer than naming.
8. Clean up only what you identified yourself. Before deleting, check dev/ino against the remembered state; if it does not match, log instead of delete. That costs five lines and prevents the kind of failure people write post-mortems about.
9. Turn failed guarantees into refusals. If the backup cannot be confirmed, the editor stays open and says why — instead of carrying on optimistically. Worth it wherever a silent loss would go unnoticed. Overkill for actions with no risk of loss, like sorting or filtering, where refusal only creates friction.
And where does the core idea itself hold up?
Separately, the question of the shape "program and data in one file". It holds where deployment is the actual problem and the data stays small: an internal notes tool on a machine without a database, a throwaway tool that arrives by scp and disappears two weeks later, a teaching example. It does not hold with multiple writers, clustered hosting, containerised filesystems, or anything that needs change detection.
There is a remarkable answer to this question from the project's own surroundings. The only fork with substance of its own — kasparsd/piplets (opens in a new tab), created on the day of my research — writes in its plan.md under "Research findings (verified, not assumed)":
"Does the data need to live inside the PHP file? No. The data section is pure JSON — the
__halt_compiler()boundary existed only because data shared the file with executable code. Once external, it is a plain.jsondocument (validated: format 2, bounded structure, lossless numbers, unique members)."
One of five stated goals of that fork, whose main goal is a bundled CLI executable: "Separates code from data: the engine stays immutable; the document is a portable pure-JSON file." So the first imitator with substance of its own removes precisely the property that defines piplet — and keeps everything else: the atomic persistence, the validation, the conflict handling.
I consider that the most honest summary this project could have received. The value is not in the boundary being torn down. It is in the care with which the consequences are caught — and that is fully transferable to programs which respect the boundary.
Conclusion
I stumbled over a repository today whose core idea I took for a curiosity, and I am ending the day with a notebook full of patterns I will reuse.
The core idea is the least interesting layer. "Data behind __halt_compiler()" is four lines, and the 78-line demo shows all of it. Everything interesting arises only from the question of what happens when those four lines have to survive real operation: two writers at once. A power cut in the wrong millisecond. A backup two weeks old. A browser that silently swallows setItem. A JSON body with 2.6 million ones in it.
The 3,506 lines of difference between the small and the large edition are almost entirely answers to those questions — and they are what makes the source worth reading, whether or not you care about wikis or WordPress or PHP. It is rare to be able to read, in such concentrated form, what durability costs in detail.
What I like most about the whole thing is the honesty of the README. It names the limits of its own approach more clearly than any reporting about it — right down to the sentence that against an exact rollback only "trusted state outside this one file, such as an append-only log or database" helps. A project that starts out with "no database" and documents the one case where it would need one has my respect.
One last thought that belongs on this blog. I have often written here that files in a directory are the most stable foundation a project can have — because you can take them with you again. piplet pushes that idea to its extreme: there is only one file left, and it contains everything. The fork next door draws the conclusion and separates again. Between those two points lies exactly the decision you make in every system that holds state — just rarely as visible as it is here.
Further reading
- Why 11ty: Six Years Static — the same longing for reduction, solved from the opposite direction.
- Sveltia CMS for 11ty — why files in a directory are the shape you can take off again.
- Working Safely With Node.js and npm — dependency freedom as a security argument, with the costs beside it.
- Who Checks the Checker? Adversarial Fact-Checking for AI Texts — why a project whose commits all come from an agent makes me measure twice.
- WordPress/piplets on GitHub (opens in a new tab) — source, README and test suite, the origin of every quote here.
- WCUS 2026 recap on wordpress.org (opens in a new tab) — the paragraph most of the reporting leans on.
- Closing Keynote — Fireside Chat with Matt Mullenweg and Robert Jacobi (opens in a new tab) — the full recording of the session, 1:07:48.
__halt_compilerin the PHP manual (opens in a new tab) — the mechanism, since PHP 5.1.0.- Ensuring data reaches disk (opens in a new tab) — Jeff Moyer's LWN article with the five-step reference for atomic replacement.