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

```wordpress
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
);
```

```minn
$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.

```wordpress
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'] ];
		},
	] );
} );
```

```minn
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]);
    }
}
```

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

```wordpress
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 ] );
} );
```

```minn
#[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]);
}
```

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.

```wordpress
$post = get_post( 12 );
if ( $post && 'publish' === $post->post_status ) {
	echo esc_html( $post->post_title );
}
```

```minn
$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.

```wordpress
$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 );
```

```minn
$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.

```wordpress
wp_mail(
	'reader@example.com',
	'Your receipt',
	"Thanks for the order.\n"
);
```

```minn
Mailer::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`](https://github.com/austinginder/minn-engine/blob/main/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.
