Stored procedures carry a bad reputation they mostly earned, because the classic version of the idea was a pile of T-SQL or PL/pgSQL living in the catalog, edited in production by whoever had the password, versioned by nobody, and reviewed never; but the reason the idea existed in the first place is still sound, which is that some operations want to happen next to the data in one round trip with one commit, and shipping three queries over the wire so your application code can glue them together is the part that was always wrong, not the procedure. MongrelDB’s take on it drops the string-of-SQL-in-the-catalog model entirely: a procedure is a structured definition with a name, a version, a mode, typed params, and a body made of ordered steps, the server checksums it when you install it, and the PHP client drives the whole lifecycle with five methods, createProcedure, procedures, procedure, callProcedure, and dropProcedure, which is the entire surface and is worth walking through because the shape of the definition is where the design decisions live.

What a procedure is in MongrelDB

The definition you install is an array that mirrors the engine’s StoredProcedure struct field for field, and the only fields you do not own are the ones the server recomputes on install, so you send placeholders for those and it fills in the truth:

$db->createProcedure([
    'name' => 'users_by_status',
    'version' => 1,
    'mode' => 'read_only',
    'params' => [
        ['name' => 'status', 'ty' => 'varchar', 'nullable' => false],
    ],
    'body' => [
        'steps' => [
            [
                'kind' => 'native_query',
                'id' => 'scan',
                'table' => 'users',
                'conditions' => [
                    [
                        'kind' => 'bitmap_eq',
                        'column_id' => 3,
                        'value' => ['kind' => 'param', 'value' => 'status'],
                    ],
                ],
                'limit' => 100,
            ],
        ],
        'return_value' => ['kind' => 'step_rows', 'value' => 'scan'],
    ],
    // Server-assigned; the install path recomputes all three.
    'checksum' => '',
    'created_epoch' => 0,
    'updated_epoch' => 0,
]);

Three things in there are doing real work. The mode is either read_only or read_write, and the engine enforces it, so a procedure that only reads can never quietly start writing underneath you. The params are typed against the same type vocabulary the columns use, each with a name, a nullable flag, and an optional default, which means the contract of the procedure is declared data and not a comment in a wiki. And the body is a list of steps whose values can reference each other, because a value in MongrelDB’s procedure language is one of literal, param, step_rows, step_row, step_scalar, object, or array; the param kind dereferences a named call argument, and the step_* kinds consume the output of an earlier step, so a write procedure can read something in step one and feed it into a put or upsert in step two, all inside one committed call, with no client round trip in the middle.

The lifecycle from PHP

Once the definition exists, the rest of the lifecycle is four more calls and none of them involve SQL strings:

use Visorcraft\MongrelDB\Database;

$db = new Database('http://127.0.0.1:8090', token: $token);

// Install or replace: POST /procedures with the definition from the section above.
$created = $db->createProcedure($definition);

// List everything installed: GET /procedures.
$names = array_column($db->procedures(), 'name');

// Fetch one definition back, checksum and all.
$def = $db->procedure('users_by_status');

// Call it. Named arguments map onto the declared params.
$rows = $db->callProcedure('users_by_status', args: ['status' => 'active']);

// Retire it: DELETE /procedures/users_by_status.
$db->dropProcedure('users_by_status');

The call path is the piece PHP fits better than any client language I have used for this, because the server expects the call arguments as a JSON object keyed by param name and PHP’s associative arrays are exactly that shape, so $db->callProcedure('users_by_status', args: ['status' => 'active']) sends {"status":"active"} and each key dereferences the param of the same name in the body; the args: label at the call site is ordinary PHP 8 named-argument syntax on the method itself, but the real mapping runs through the array keys, which is worth saying precisely because callProcedure('users_by_status', status: 'active') would not work, and the habit of naming arguments that PHP developers picked up in 8.0 still lines up nicely with a contract where every param is declared with a name and a type on the server side. One implementation detail in the client is worth knowing because it explains a whole class of confusing bugs you will now never have: callProcedure casts the args array to an object before encoding, since an empty PHP array serializes as [], a JSON sequence, while the server requires {}, a map, and that one cast is the difference between a procedure with no arguments working and the server rejecting your payload shape.

What steps can do

The step vocabulary covers the operations the engine can commit atomically: native_query reads a table through conditions (pk, bitmap_eq, bitmap_in, range, range_f64, is_null, is_not_null, and fm_contains for full-text substring matching) with an optional projection and limit, and put, upsert, delete_by_pk, and delete_rows mutate, with returning on the writes when you want the rows back. Because every value in a step can be a param reference or a step_scalar from earlier in the list, the read-then-write pattern that usually takes an application-side transaction, select the id, compute something, insert the child row, compresses into one call:

[
    'kind' => 'put',
    'id' => 'record_hit',
    'table' => 'counters',
    'cells' => [
        ['column_id' => 1, 'value' => ['kind' => 'param', 'value' => 'key']],
        ['column_id' => 2, 'value' => ['kind' => 'literal', 'value' => ['Int64' => 1]]],
    ],
    'returning' => true,
]

Two value shapes in that block deserve a sentence of their own, because they look asymmetric until you know why they differ: a literal wraps one of the engine’s typed values in a one-key tag, ['Int64' => 1], since the wire format has to know whether your 1 is an integer, a float, or a decimal128 before it can check the value against the column type, while a param value is a bare string because it carries no data at all and merely names the call argument to substitute at execution time.

Managing the catalog of procedures is a DDL operation, so installing, listing, describing, and dropping all require a principal with DDL permission, while calling one runs under the normal transaction discipline with the caller’s table grants checked the same way any other operation would be; the procedure is not a way to escape permissions, it is a way to pre-arrange work the caller was already allowed to do.

The honest tradeoff

The constraint to design around is that procedures called over the HTTP endpoint execute in the core engine, which does not host the SQL query layer, so a sql_query step is rejected there and your read steps are built from the native conditions instead; that is a deliberate boundary, because arbitrary SQL at call time is what the /sql endpoint is for, and the procedure surface stays limited to operations the engine can validate, checksum, and commit deterministically. The old world gave you unlimited power inside the procedure and paid for it with unreviewable string soup drifting in the catalog, the modern equivalent is a definition that is data, with a version you bump when the body changes and a checksum the server owns, and if you genuinely need free-form SQL in the middle of a procedure the honest answer is that MongrelDB has decided you do not, at least not over this endpoint, which is a smaller toolbox and, on the Fridays when the old catalog procedures used to break, a noticeably quieter one.