The same job, twice

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 ?.

WordPressthe idiom plugins write9 lines
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
);
Minnsrc/Minn5 lines
$rows = $db->rows(
    "SELECT ID, post_title FROM {$db->table('posts')}
     WHERE post_status = ? AND post_type IN (?) LIMIT ?",
    ['publish', ['post', 'page'], 10],
);
4 fewer lines

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.

WordPressthe idiom plugins write11 lines
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'] ];
		},
	] );
} );
Minnsrc/Minn8 lines
final readonly class ItemsController
{
    #[Route(Method::Get, '/acme/v1/items/{id:\d+}', policy: new Policy(Access::Cap, 'edit_posts'))]
    public function show(Request $request, string $id): Response
    {
        return Response::json(['id' => (int) $id]);
    }
}
3 fewer lines

{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.

WordPressthe idiom plugins write12 lines
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 ] );
} );
Minnsrc/Minn6 lines
#[Route(Method::Post, '/acme/capture', policy: new Policy(Access::Public))]
public function capture(Request $request): Response
{
    $email = (string) ($request->form['email'] ?? '');
    return Response::json(['ok' => true, 'email' => $email]);
}
6 fewer lines

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.

WordPressthe idiom plugins write4 lines
$post = get_post( 12 );
if ( $post && 'publish' === $post->post_status ) {
	echo esc_html( $post->post_title );
}
Minnsrc/Minn4 lines
$post = $posts->find(12);
if ($post?->isPublished()) {
    echo Html::esc($post->title);
}
same length

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.

WordPressthe idiom plugins write9 lines
$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 );
Minnsrc/Minn5 lines
$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();
4 fewer lines

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.

WordPressthe idiom plugins write5 lines
wp_mail(
	'reader@example.com',
	'Your receipt',
	"Thanks for the order.\n"
);
Minnsrc/Minn5 lines
Mailer::mail(
    'reader@example.com',
    'Your receipt',
    "Thanks for the order.\n",
);
same length

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.