How the PHP reads
The engine speaks WordPress at its seams and nowhere else. Table names, route paths, JSON keys, cookie names, file names: those are fixed by compatibility, and they are ugly on purpose. Everything behind them is ours, and it should be a joy to read.
Existing plugins keep calling $wpdb->prepare() and new WP_Query(). That is the product. This page is a vanity metric. It puts that idiom next to the PHP in src/Minn/, so you can see what modern PHP means here.
The left column is plugin code, WordPress coding standards and all. The right column is the same job as a Minn class would write it: strict types, PSR-12, named arguments, Request in and Response out. Both run. The left one is how a plugin talks to the engine. The right one is how the engine talks to itself.
Registering a post type, adding a hook, reading an option: those stay the WordPress calls. There is no second vocabulary for the jobs plugins already know.
A prepared query
$wpdb->prepare with %s / %d placeholders is the WordPress door to SQL. Minn has one door, Minn\Db, and the placeholder is always ?. Types come from the PHP values. A list stands for a list: IN (?) takes the array itself, and nobody counts placeholders.
One placeholder, typed from the value. A list is one ?.
global $wpdb;
$types = [ 'post', 'page' ];
$marks = implode( ',', array_fill( 0, count( $types ), '%s' ) );
$sql = "SELECT ID, post_title FROM {$wpdb->posts}";
$sql .= " WHERE post_status = %s AND post_type IN ({$marks}) LIMIT %d";
$rows = $wpdb->get_results(
$wpdb->prepare( $sql, 'publish', ...$types, 10 ),
ARRAY_A
);$rows = $db->rows(
"SELECT ID, post_title FROM {$db->table('posts')}
WHERE post_status = ? AND post_type IN (?) LIMIT ?",
['publish', ['post', 'page'], 10],
);Table names go through $db->table('posts'), so the prefix from wp-config.php is applied in one place. global $table_prefix never appears.
A REST route
Plugins still call register_rest_route on rest_api_init. That is Speak. The engine's own controllers declare the same facts as method attributes: the verb, the pattern, the capability. The router enforces the cap before the handler runs, so the authorization surface is a grep away.
The verb, the path, and the capability sit on the handler itself.
add_action( 'rest_api_init', function () {
register_rest_route( 'acme/v1', '/items/(?P<id>\d+)', [
'methods' => 'GET',
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'callback' => function ( WP_REST_Request $request ) {
return [ 'id' => (int) $request['id'] ];
},
] );
} );{id} captures a segment, {id:\d+} constrains it, {path*} takes the rest of the path. Handlers receive only the captures they declare.
Capture a request
The WordPress idiom reads $_POST and $_SERVER, sanitises by hand, and exits through wp_send_json. Minn handlers never touch a superglobal. The kernel builds one Request at the edge. The handler returns a Response. Nothing is written until send().
A Request comes in, a Response goes out, and nothing is written in between.
add_action( 'init', function () {
if ( ( $_SERVER['REQUEST_METHOD'] ?? '' ) !== 'POST' ) {
return;
}
if ( ( $_GET['acme'] ?? '' ) !== 'capture' ) {
return;
}
$email = sanitize_email(
wp_unslash( $_POST['email'] ?? '' )
);
wp_send_json( [ 'ok' => true, 'email' => $email ] );
} );No header(), no echo, no exit in the handler. A response can be inspected, wrapped, or replaced on the way out.
Read a post
WP_Query is the loop every plugin already knows, and the engine lists through it too: every page of posts it shows is the main query, so pre_get_posts and the posts_* filters shape it, and there is no second listing API beside it. Reading one post for itself is a repository call that hands back a record.
One listing, WordPress's. One post is a record with plain names.
$post = get_post( 12 );
if ( $post && 'publish' === $post->post_status ) {
echo esc_html( $post->post_title );
}$post = $posts->find(12);
if ($post?->isPublished()) {
echo Html::esc($post->title);
}A PostRecord has the columns by plain name (->title, ->slug, ->status) and answers the questions a reader asks (isPublished(), isProtected()). Rendering and escaping happen at the output boundary, not inside the query.
An outgoing request
wp_remote_get returns an array or a WP_Error, and three helper functions read it back. Minn\Http is called where you are, as \Minn\Http::get(), with no use line, and returns an Exchange: status, headers, body, or a transport error, with the questions you ask of it as methods.
One call, no import, and one object answers every question you ask of the reply.
$response = wp_remote_get( 'https://api.example.com/status', [
'timeout' => 5,
'headers' => [ 'Accept' => 'application/json' ],
] );
if ( is_wp_error( $response ) ) {
return $response->get_error_message();
}
$code = wp_remote_retrieve_response_code( $response );
$data = json_decode( wp_remote_retrieve_body( $response ), true );$reply = \Minn\Http::get('https://api.example.com/status', headers: ['Accept' => 'application/json'], timeout: 5);
if (!$reply->ok()) {
return $reply->error ?? "HTTP {$reply->code}";
}
$data = $reply->json();Every verb (get, head, delete, post, put, patch) takes query:, headers:, timeout: and private: by name; post, put and patch also take json: or form: to encode a body. A misspelt option fails at the call. ->throw() turns anything but a 2xx into an exception, for callers who would rather catch. A private address is refused unless the call lists it in private:, at every redirect and after DNS. In a test, Minn\Http::fake() answers in place of the network, and it answers a plugin's wp_remote_*() calls too, because both arrive at the same door.
Send an email
wp_mail() is three arguments and a transport chosen somewhere behind it. Minn's is the same three arguments. On a booted site the message goes through wp_mail() itself, so an SMTP plugin's phpmailer_init still applies. The site's minn_mail option, when it is set, picks mail(), SMTP, or a log file, and supplies the sender.
The same three arguments. The transport is the site's business, not the caller's.
wp_mail(
'reader@example.com',
'Your receipt',
"Thanks for the order.\n"
);A caller that wants the transport in hand, to send several or to fake one in a test, takes Mailer::forSite($site) and calls send(Message) on it. The engine's own messages (a password reset, a new account, a comment to moderate) are worded once, in Notices. Failures return false. Nothing here throws into a request.
What you are seeing
These snippets are the shape, not a dump of production files. The classes they name are real: Db, Route, Policy, Access, Request, Response, Posts, Terms, PostRecord, Html, Http, Exchange, Mailer, Notices. A style suite pins the engine to this shape: strict types, PSR-12, no free functions in src/Minn/, no queries and no 40-line functions in the WordPress facade.
Writing more of it is a short read: docs/writing-minn.md in the repository walks a first controller, a first block and a first extension to a passing test, and CONTRIBUTING.md is the rules on one page.
The facade (wp-api/) stays a mapping layer on purpose. Plugin PHP comes in as wp_remote_get( $url, $args ). One Minn call does the work. The return is shaped the way the reference shapes it. New behaviour belongs in a small final class, not in the mapping.
That is the whole argument in code form. Compatible at the seam. A joy to read behind it.