The host contract

What a host inspects

This is an audit of the smallest surface a custom application has to speak so a host of WordPress sites accepts the webroot. It is not a guide to forking WordPress software, and it is not a copy of that software. It is a map of the contracts hosts, backup tools, monitors, and WP-CLI actually inspect.

The point of writing it down is that the map is much smaller than the tree. Once the map exists, the same application can be written in PHP, Go, Rust, or anything else, as long as it still presents this shape.

WordPress 7.1 is the release whose contracts this page names.

The three layers

The webroot is one directory a host of WordPress sites already knows how to operate. Three things occupy it. They are not three packages. Data sits inside adaptor folders because that is where backups already look. Minn is the exception: one folder of its own.

Three things occupy one webroot. They are not three packages. Data sits inside adaptor folders because that is where backups already look.

Webroot
public/
minn/ engine + admin
admin/ Minn Admin
assets/
layout/ templates for the seams
src/Minn/ the engine
bootstrap.php
wp-admin/ directory must exist or nginx 403s
index.php 302s to /minn-admin/
wp-content/ the path backups already walk
mu-plugins/
host-helper.php often the host's
plugins/
akismet/ this site's plugins
themes/
twentytwentyfive/ this site's theme
uploads/
2026/08/hero.jpg this site's media
object-cache.php drop-in, often injected
wp-includes/
*.php empty placeholders, not WordPress
version.php $wp_version = '7.1'
index.php seam: defines ABSPATH
wp-cli.yml require: minn/cli.php
wp-config.php this site's secrets; never rewrite
wp-login.php must exist as a file
wp-settings.php seam: boots minn/
Database

The dump. Twelve wp_* tables.

wp_posts
wp_postmeta
wp_options
wp_users
wp_usermeta
wp_terms
wp_term_taxonomy
wp_term_relationships
wp_termmeta
wp_comments
wp_commentmeta
wp_links

Fork vs from scratch

Forking WordPress and building something WordPress-compatible are different jobs. They get mixed up because both produce a directory that a host will call "a WordPress site."

A fork keeps the tree. ClassicPress is the clean example: it kept the contracts and stayed a fork, so the license came with the files. Every wp-includes/.php file is still WordPress's file, or a descendant of one. The host is happy because the files are there. The GPL is not optional, because the implementation is* the files.

A from-scratch application keeps the contracts and throws the tree away. Nginx did this to Apache's HTTP. Samba did this to SMB. Wine did this to Win32. The surrounding world does not move. Compatibility is the product, and the implementation is a new work.

WordPress makes the second job look harder than it is, because what people call "the WordPress standard" is not a published protocol. It is a pile of PHP files. Hosts, WP-CLI, backup plugins, and migration tools do not read a spec. They file_exists() a path, regex a config, parse $wp_version out of wp-includes/version.php, and run wp option get home. If the file is missing, the host does not treat the directory as a WordPress site. If the file is present and the output shape matches, it does.

That is the trap. Meeting the standard looks like "ship all of those files." Shipping all of those files is a fork. The files are the interface. Their contents, except for a handful of parseable assignments and a boot chain that a regex can follow, are the implementation. A compatible application has to put the interface on disk. It does not have to put WordPress inside it.

Including WordPress GPL code is how you fork. Matching the file names, the table names, the HTTP paths, and the CLI verbs is how a host of WordPress sites accepts the webroot.

Minn Engine is one implementation of this map. The map is the point of the page. Another runtime in another language can follow it without carrying any of Minn, and without carrying any of WordPress.

The standard is a pile of files

WordPress 7.1 ships about 1,315 PHP files and 2,130 files in total, 1.8 million lines, once the bundled themes and plugins are left out. Roughly a thousand of those PHP files live under wp-includes/. Another 242 live under wp-admin/. Fourteen PHP files sit at the webroot.

There is no document that says "these fourteen are the host contract, and these thousand are the runtime." The installer drops the whole tree. Hosting panels treat the whole tree as the install. wp core verify-checksums hashes thousands of those files against wordpress.org. A missing wp-includes/functions.php is, to that world, a broken WordPress site.

For a host that is trying to operate the site, almost none of that tree is load-bearing. The operator needs to:

  1. Detect that the webroot is a WordPress site.
  2. Read database credentials without running the application.
  3. Open a MySQL database whose tables match the wp_* schema.
  4. Hit a few well-known URLs and not get 404s.
  5. Run a handful of WP-CLI verbs whose stdout they already parse.
  6. Back up files plus a database dump, and restore them somewhere else.

Those six jobs are the adapter. Everything else (the admin screens, the block editor packages, TinyMCE, the Customizer, the PHP functions a plugin would call) is software that happens to live in the same tree. A from-scratch application that wants a web host to operate it the way it operates a WordPress site implements the adapter. Looking like WordPress software to a plugin is a different, much larger contract, and it is not what this page is for.

What a host is

In this document, "host" means the operational layer around the site, not a person in a browser:

Humans using /wp-admin/ are not this audience. Minn answers that audience with Minn Admin. A from-scratch app can answer it however it wants.

Files on disk

This is the layer that makes wp core version and a hosting panel treat the directory as a WordPress site. PHP-FPM and nginx decide it before any application code runs: a missing .php file is a 404, and a directory without an index is often a 403. The files have to exist as files.

The boot chain

An unmodified wp-config.php is the whole trick. Backup tools, WP-CLI config and db commands, and host panels parse that file as text. They must not have to edit it. The stock file ends with require_once ABSPATH . 'wp-settings.php'. Whatever sits at wp-settings.php is the application.

index.php
  defines ABSPATH as this directory
  requires wp-config.php

wp-config.php          (the file WordPress generated; never rewrite it)
  defines DB_* and the eight KEY/SALT constants
  sets $table_prefix
  requires wp-settings.php

wp-settings.php
  boots your application

index.php must define ABSPATH before loading the config. WordPress itself does this in wp-load.php. Custom configs test defined('ABSPATH'). If the constant is missing, those configs fatally assume they are being included the wrong way.

Shape files a host needs

PathWho reads itWhat it must do
index.phpThe web server, WP-CLI (extract_subdir_path)Define ABSPATH, require wp-config.php.
wp-config.phpEvery backup and migration tool (regex), WP-CLI config and dbExist, in the generated shape. Do not ship it. Do not rewrite it.
wp-settings.phpThe last line of wp-config.phpBoot the application. Two lines is enough.
wp-includes/version.phpWP-CLI (wp_exists() is file_exists on this path; core version parses assignments by string search), hosting panels, backup toolsAssign $wp_version, $wp_db_version, $tinymce_version. The values name the release whose contracts you speak. The file contains none of that release's code.
wp-login.phpnginx (a missing .php is a 404 before PHP runs), hide-login plugins that require ABSPATH . 'wp-login.php', monitorsExist. Answer GET with a sign-in form or a 302. Never 404.
wp-admin/index.phpnginx (a directory without an index is a 403), monitors, panelsThe directory and this file must exist on disk. A 302 away from /wp-admin/ is fine. A 404 is the one status that tells a panel the directory is not a WordPress site.
wp-cli.ymlWP-CLI, if you intercept verbs before WordPress would loadOptional. The way a from-scratch app registers commands without shipping wp-load.php.

wp core version does not boot the application. It includes wp-includes/version.php and prints $wp_version. A one-assignment file is a complete implementation of that verb:

<?php
$wp_version = '7.1';
$wp_db_version = 61833;
$tinymce_version = '49110-20250317';
$required_php_version = '7.4';
$required_mysql_version = '5.5.5';

WP-CLI also wants $wp_version >= 3.7 after including the file. Hosting panels string-search the same assignments. Matching the format ($wp_version = '7.1'; on its own line) matters more than matching comments.

wp-content/

PathRole
wp-content/plugins/Plugin folders, or a single-file slug.php. Headers in the plugin file are how wp plugin list gets name, title, and version.
wp-content/themes/Theme folders. style.css headers are how wp theme list gets name and version. Template: names the parent.
wp-content/uploads/Media. Convention is YYYY/MM/filename. The web server serves these as static files.
wp-content/mu-plugins/Must-use plugins. Hosts drop their own helpers here. They must be allowed to exist.
wp-content/languages/Translation packs. Optional for detection.
Drop-ins (object-cache.php, advanced-cache.php, db.php, maintenance.php, sunrise.php, …)Hosts inject these. Redis object cache is object-cache.php. They must be allowed to sit in wp-content/. Loading them is a runtime concern; forbidding the filename is how you get caught.

Plugin file header, first docblock in the main PHP file:

Plugin Name: Example
Version: 1.0.0

Theme header, top of style.css:

Theme Name: Example
Version: 1.0.0
Template: parent-slug

wp plugin list --format=json --fields=name,title,status,version (CaptainCore's inventory) is a directory walk plus the active_plugins option. You do not have to run the plugin. You have to report it.

Files that are deliberately not required

These exist in a WordPress tree and nothing in the host layer fails if your application answers them another way, or not as files:

PathWhy it is in the treeCompatible stand-in
wp-load.php, wp-blog-header.phpWordPress's own include chainNot needed if index.php loads the config. WP-CLI finds the root through index.php or the working directory.
xmlrpc.phpXML-RPCThe router can answer /xmlrpc.php. A file is nicer for nginx, not required if the host sends unknown .php through the front controller (Cove does; Kinsta does not).
wp-cron.phpPseudo-cronSame as xmlrpc: a file on hosts that 404 missing .php, or a route on hosts that do not. GET/POST should return 200 with an empty body.
wp-comments-post.php, wp-mail.php, wp-links-opml.php, wp-trackback.php, wp-activate.php, wp-signup.phpLegacy endpointsNot what a host probes.
readme.html, license.txtVersion fingerprinting in the browserOptional. Some scanners use them. They are not an operational surface.
The rest of wp-includes/ and wp-admin/The implementationPlaceholders so require ABSPATH . 'wp-admin/includes/plugin.php' resolves. Empty PHP files. Not WordPress code.

The last row is the whole argument in miniature. Plugin code and host mu-plugins write require ABSPATH . 'wp-includes/plugin.php'. On a WordPress host that request is a file read, not a protocol. The compatible answer is an empty file at that path, with the symbols provided by your runtime (or not, if you are not running plugin PHP). The compatible answer is not "copy plugin.php out of WordPress." Copying it is a fork.

wp-config.php

Treat this file as data. Hosts and tools read it with regexes. They do not want to run it, and a from-scratch installer must not rewrite it.

What the regexes look for

define( 'DB_NAME',     'database_name_here' );
define( 'DB_USER',     'username_here' );
define( 'DB_PASSWORD', 'password_here' );
define( 'DB_HOST',     'localhost' );
define( 'DB_CHARSET',  'utf8mb4' );
define( 'DB_COLLATE',  '' );

define( 'AUTH_KEY',         'put your unique phrase here' );
define( 'SECURE_AUTH_KEY',  'put your unique phrase here' );
define( 'LOGGED_IN_KEY',    'put your unique phrase here' );
define( 'NONCE_KEY',        'put your unique phrase here' );
define( 'AUTH_SALT',        'put your unique phrase here' );
define( 'SECURE_AUTH_SALT', 'put your unique phrase here' );
define( 'LOGGED_IN_SALT',   'put your unique phrase here' );
define( 'NONCE_SALT',       'put your unique phrase here' );

$table_prefix = 'wp_';

require_once ABSPATH . 'wp-settings.php';

A parser that only handles quoted string literals will miss real configs. Tolerate at least:

Hosts also inject extra define()s (DISABLE_WP_CRON, FS_METHOD, DISALLOW_FILE_EDIT, AUTOMATIC_UPDATER_DISABLED, WP_CACHE, WP_CONTENT_DIR). Ignore unknown constants. Do not round-trip the file.

WP-CLI's config get and db * (the ones that run after_wp_config_load) strip the trailing require, eval the rest, and talk to MySQL with those credentials. If wp-includes/version.php exists and wp-config.php is parseable, wp db export works without your application existing at all.

Twelve tables

The portable unit of a WordPress site is files plus a database dump. The dump is MySQL/MariaDB. The table prefix comes from $table_prefix. The twelve core tables, with the prefix stripped:

TableWhat lives there
postsPosts, pages, attachments, revisions, navigation, templates, and every other post type. One table, discriminated by post_type and post_status.
postmetaPer-post key/value. meta_value is often a serialized PHP blob.
optionsSite options. option_name is unique. option_value is often a serialized PHP blob. autoload is yes/on/auto-on or no/off/auto-off.
usersAccounts. user_pass holds the password hash.
usermetaPer-user key/value. Primary key column is umeta_id, not meta_id. Session tokens, capabilities, and locale live here.
termsTerm names and slugs.
term_taxonomyTerm plus taxonomy, description, parent, and count. Reads trust the stored count.
term_relationshipsObject to term_taxonomy_id.
termmetaPer-term key/value.
commentsComments and the comment-shaped rows (comment_type).
commentmetaPer-comment key/value.
linksThe old blogroll. Empty on modern sites. Still there.

Anything else (wp_woocommerce_, wp_wf, …) is plugin data. A host dump includes it. An adapter that only speaks core can ignore extra tables and must not drop them.

Columns that tooling actually uses

posts

ColumnNotes
IDUnsigned bigint, primary key.
post_authorUser ID; 0 is authorless (wp-cli's default).
post_date, post_date_gmtLocal and GMT. 0000-00-00 00:00:00 is a real stored value.
post_contentThe field. Blocks, classic HTML, or anything else.
post_title, post_excerptText.
post_statuspublish, draft, future, pending, private, trash, auto-draft, inherit.
post_nameSlug.
post_modified, post_modified_gmt
post_parentPages, attachments, revisions.
guidStored as written. Search-replace tools skip this column on purpose.
menu_orderPages.
post_typepost, page, attachment, revision, plus the rest.
post_mime_typeAttachments.
comment_countStored. Writers recount.

options, the keys fleet tooling reads first: home, siteurl, blogname, blogdescription, admin_email, template, stylesheet, active_plugins, permalink_structure, cron, site_icon, WPLANG. home and siteurl are how a host knows the public URL. template / stylesheet are the active theme folder (and its parent). active_plugins is a serialized PHP array of folder/file.php paths.

users.user_pass, modern scheme, observed from running WordPress: a $wp$ prefix, then bcrypt over base64(hmac_sha384(password, "wp-sha384")). Legacy $P$ (phpass) still exists on old rows. A host does not hash passwords, but a tool that logs in (or that copies users between sites) needs rows in this shape.

Serialized PHP is forever

option_value and the _meta value columns store PHP's serialize() output: a:1:{i:0;i:5;} for sticky_posts, similarly for active_plugins, role objects, and a long tail of plugin data. Search-replace tools (including wp search-replace) walk those blobs without* a full unserialize, because a naive byte replace breaks the length prefixes.

A from-scratch application has to:

This is the one encoding in the adapter that is PHP-shaped even if the application is not PHP. The dump is the portable unit. The dump talks this encoding.

HTTP probes

Monitors and panels request a short list of URLs that are not "a page on the site." Status codes matter more than bodies. A 404 on these paths is how you fail the Turing test.

URLExpectedWhy
GET /200The site is up.
GET /wp-login.php200 (form) or 302 (to your own sign-in)Monitors, wp user login links, scripted sign-in. A 404 is fatal.
GET /wp-admin/ and anything under it302 (login, or your admin)Same. The directory must exist on disk so nginx does not 403 it first.
GET /wp-cron.php200, empty bodyHosts and plugins poke this. Honour DISABLE_WP_CRON if you do work here.
GET /xmlrpc.php405, Allow: POSTA 404 is a tell. POST can refuse (many hosts disable XML-RPC themselves).
GET /robots.txt200, four lines including the sitemap URL; or Disallow: / when the site is not publicCrawlers. Cheap to match.
GET /wp-json/ or GET /?rest_route=/200, JSON indexThe REST discovery document.
Link: <{home}/wp-json/>; rel="https://api.w.org/"Response header on HTML pagesDiscovery without fetching the index.
GET /feed/200, RSS 2.0Optional for a host, expected by feed readers. <generator> carries the recorded core version.
GET /wp-sitemap.xml200Optional for a host.
GET /favicon.ico302 to the site icon, or 404Optional.

Pretty permalinks (/hello-world/) are a public-site concern. Hosts with nginx/Caddy already send unknown paths to index.php. .htaccess rewrite rules are for Apache; keep them if you find them, do not require them.

REST, as a host sees it

The adapter does not need the full wp/v2 surface for a host to be happy. It needs:

The rest of wp/v2 (posts, media, users, comments) is how an admin application speaks to the site. Minn Admin needs it. A host's backup job does not.

WP-CLI

WP-CLI is MIT. The wp binary runs unchanged. Detection is:

  1. Find a webroot (working directory, or index.php in a parent).
  2. file_exists( wp-includes/version.php ). If missing: Error: This does not seem to be a WordPress installation.
  3. Phase before_wp_load: commands that opted in. This is where a from-scratch app registers verbs, via wp-cli.yml require:.
  4. Phase after_wp_config_load: wp db and wp config , using the parsed config. Needs version.php plus wp-config.php. Does not need your application.
  5. Phase after_wp_load: everything else. On WordPress this loads the full runtime. On a from-scratch app that does not ship that runtime, refuse cleanly rather than fatal.

Verbs fleet tooling actually runs

CaptainCore, Disembark, and typical host panels have been observed to call:

VerbWhy
wp core versionInventory. Parsed from version.php.
wp option get home / option get siteurlPublic URL. --skip-plugins --skip-themes and --url= are common.
wp option update / option setLaunch and migrate scripts.
wp plugin list --format=json --fields=name,title,status,versionInventory.
wp theme listInventory.
wp plugin install / theme install / activate / deactivateUpdates and provisioning.
wp user list / user get / user createProvisioning.
wp user login <user>Magic-link sign-in (CaptainCore helper contract: a short token on wp-login.php).
wp search-replace <old> <new> --all-tables --report-changed-onlyDomain changes on launch and migrate. Must walk serialized PHP arrays without breaking prefixes. Skip guid.
wp db export / db query / db checkBackups and health. WP-CLI's own, after config load.
wp cache flushAfter writes. "Success: The cache was flushed." even when there is no object cache, so scripts do not abort.
wp rewrite flushAfter permalink changes.
wp maintenance-mode (activate / deactivate)Bookends updates. Implemented as {ABSPATH}/.maintenance.

Stdout wording is part of the contract. Callers match Success: and Error: lines. Exit codes too: missing option is exit 1, missing plugin on is-installed is exit 1 with no output.

You do not need wp post list for a host. You do not need wp core update until you have a release of your own; that command becomes your updater, not a rewrite of WordPress's. wp core verify-checksums will fail against wordpress.org, because you do not ship those files. That failure is the truthful signal. Do not fake the hashes.

Shared auth

A session is shared between the browser, the application, and whatever WordPress tooling still touches the site. Hosts that open the admin, magic-link logins, and REST clients all depend on the same cookies.

Cookie name: wordpress_logged_in_ plus md5(siteurl). Over HTTPS an additional wordpress_sec_{hash} cookie is set on /wp-admin and /wp-content/plugins; over HTTP it is wordpress_{hash}. The logged-in cookie is the one REST reads, path /.

Cookie value: username|expiration|token|hmac.

The HMAC key is derived from a four-character fragment of user_pass. Observed rule: for a $wp$-prefixed hash, the fragment is the last four characters; for legacy phpass $P$, it is substr(hash, 8, 4). Getting this wrong produces a cookie that parses, finds the user, finds a live session, and still fails the HMAC, which looks like a salt mismatch.

The token's sha256 must be a live key in the user's session_tokens usermeta.

REST nonce (wp_rest action): substr(HMAC-md5(tick|wp_rest|uid|token, NONCE_KEY . NONCE_SALT), -12, 10), with tick = ceil(time / 43200). Current and previous tick both accepted.

Status split, observed: cookie failure is 401 rest_not_logged_in; nonce failure is 403 rest_cookie_invalid_nonce. Clients use that split to decide between re-authenticating and refetching a nonce.

None of this requires WordPress source. It is a keyed-hash protocol over values in wp-config.php and wp_usermeta. Any language with HMAC-MD5, HMAC-SHA256, and bcrypt can implement it. Cross-acceptance (a cookie minted by the application authenticates against WordPress, and the other way around) is the proof the protocol was matched.

The portable unit

Backup, migrate, staging, and "download a copy of my site" all assume the same bundle:

  1. The files under the webroot, especially wp-content/ and wp-config.php.
  2. A SQL dump of every table with the site's prefix.

Restore is: put the files on a PHP host, import the dump, wp search-replace old-host new-host --all-tables, and visit /wp-login.php. A from-scratch application that can ingest that bundle and emit that bundle is operated the same way a WordPress site is, even if not one line of WordPress software runs.

Disembark's phar does not even read the shape files by name. It works from the database credentials it is given. UpdraftPlus reads wp-config.php and dumps wp-content/ plus the database. CaptainCore's file hashes (component-hashes, quicksave-fingerprint) are bash over the filesystem.

The adapter's job on this layer is: do not invent a new layout, do not invent a new schema, do not invent a new encoding for options.

What is not required

A host that can detect, back up, migrate, monitor, and run WP-CLI against the site does not need:

Those absences are how you tell a from-scratch application from a fork. A fork has the files because it is the files. A compatible application has the handful of paths the host reads, and is honest about the rest.

What gives you away, if you care

SignalWho looksHonest response
wp core verify-checksums failsHost integrity scans, CaptainCoreCorrect: you do not ship WordPress. Do not impersonate the hashes.
Missing wp-includes/functions.php as a real implementationPlugin PHP, some mu-pluginsEmpty placeholder if you load plugin PHP; omit if you do not.
GET /wp-admin/ is 404Monitors, panelsAlways 302.
GET /wp-login.php is 404Monitors, magic linksAlways exist as a file, then 200 or 302.
wp-includes/version.php missingWP-CLI, panelsAlways exist, with parseable assignments.
Rewritten wp-config.phpBackup toolsNever.
Extra tables dropped on migrateAny dump/restoreNever.
Serialized PHP rewritten as JSONSearch-replace, eject back to WordPressNever, for stored options and meta.

Being operated like a WordPress site is not the same as hiding that you are something else. The truthful tells (checksums, wp minn version, a generator string that names your engine) can stay. The operational tells (404s, missing version.php, a rewritten config) cannot.

Another language

The contracts above are file names, SQL, HTTP, and CLI stdout. None of them require the application to be PHP. Two constraints still bind.

The host runs PHP. On a platform that hosts WordPress sites, nginx sends *.php to PHP-FPM. A Go binary sitting at the webroot will not be executed. The compatible pattern is thin PHP at the well-known paths (index.php, wp-settings.php, wp-login.php, wp-admin/index.php, wp-includes/version.php) that boots, or proxies to, your runtime. Those stubs are original work: define a constant, require a config, hand off. They are not a fork.

WP-CLI is a PHP phar. Verbs you need either register at before_wp_load through wp-cli.yml, or they are WP-CLI's own (config, db) and only need the config file. You do not have to load a WordPress runtime to export a database.

The database is MySQL. The option encoding is serialized PHP. The auth scheme is HMAC plus bcrypt. HTTP is HTTP. A Rust service that speaks those, with five PHP stubs at the webroot, is operated by Kinsta the way Kinsta operates a WordPress site. A Rust service with none of the stubs is a different kind of host.

That is the whole license argument from the other direction. The stubs, the table names, the route paths, and the CLI verbs are the interface. Interfaces are not copyrightable expression. The thousand PHP files under wp-includes/ are expression. A from-scratch application implements the first and does not copy the second. A fork copies the second and inherits the license. The reason WordPress feels like it can only be forked is that the interface and the expression are shipped in the same pile of files, and nobody wrote the interface down.

This page is that interface.

How this was written

Captured from a running WordPress 7.1 (the "oracle") and from the tools that operate real sites: WP-CLI, CaptainCore, Disembark, Kinsta's file-on-disk behaviour, UpdraftPlus-style backup. Observed behaviour, not WordPress source. Comparing outputs is always legal. Copying wp-includes/functions.php is how you accidentally fork.

Minn Engine is one implementation of these contracts, in PHP, MIT licensed, with fixture suites that pin the shapes. The contracts live next to that engine. They are not the engine. If this page is useful, it is because someone can follow it in a language Minn does not speak.