cURLpit

Curlpit is a PSR-15 middleware orchestrator micro framework for PHP – with branching, looping, declarative flow control, and multi-API aggregation built in.

Most middleware stacks are pipelines: request goes in, response comes out, linearly. Curlpit treats the middleware stack as an instruction sequence – with a program counter, named labels, conditional jumps, loops, and collection mapping. Business logic that would otherwise be hardcoded in handlers can be expressed as configuration.

cURLpit isn’t meant to replace your application logic entirely — only the parts that refuse to stay static.

Installation

composer require curlpit/curlpit

Then install a PSR-7/17 implementation of your choice:

# nyholm/psr7 (recommended – lightweight, zero dependencies)
composer require nyholm/psr7 nyholm/psr7-server

# or guzzlehttp/psr7
composer require guzzlehttp/psr7

How it differs

  Standard PSR-15 Curlpit
Execution Linear chain Instruction sequence (program counter)
Branching None JumpMiddleware, IfMiddleware
Looping None LoopMiddleware + LoopContext
Collection mapping None MapMiddleware + CollectMiddleware
Flow control None BreakMiddleware, ContinueMiddleware, HaltMiddleware
HTTP client None HttpMiddleware (Guzzle → cURL → file_get_contents)
State Informal Explicit Set/Get/Inc/Dec middleware
Error handling Manual Built-in ErrorHandlerMiddleware
Config Code only JSON-declarable with use aliases
Environment Hardcoded {env:VAR} token interpolation

Quick start

Extend Application, override instantiate() to wire up your dependencies, point it at a middleware.json:

use Curlpit\App\Application;
use Curlpit\Core\Emitter;

class MyApp extends Application
{
    protected function instantiate(string $class, array $options): MiddlewareInterface
    {
        return match ($class) {
            MyMiddleware::class => new MyMiddleware($this->responseFactory),
            default             => parent::instantiate($class, $options),
        };
    }
}

$app      = (new MyApp($responseFactory, $streamFactory))
                ->withConfig(ConfigLoader::fromFile(__DIR__ . '/middleware.json'));
$response = $app->handle($serverRequest);
(new Emitter())->emit($response);

Flow config (middleware.json)

{
  "middleware": [
    { "Curlpit\\Core\\Middleware\\ErrorHandlerMiddleware": { "debug": false } },
    { "My\\AuthMiddleware": {} },
    {
      "Curlpit\\Core\\Middleware\\JumpMiddleware": {
        "condition": { "type": "attr", "name": "user_role", "eq": "admin" },
        "jump_to_label": "admin"
      }
    },
    { "My\\PublicDispatch": {} },
    { "My\\AdminDispatch": { "label": "admin" } }
  ]
}

use aliases

Avoid repeating full class names throughout middleware.json. Aliases are resolved at config load time by ConfigLoader, including inside nested middleware blocks:

{
    "use": {
        "Http":     "Curlpit\\Core\\Middleware\\HttpMiddleware",
        "Map":      "Curlpit\\Core\\Middleware\\MapMiddleware",
        "Collect":  "Curlpit\\Core\\Middleware\\CollectMiddleware",
        "If":       "Curlpit\\Core\\Middleware\\IfMiddleware",
        "Break":    "Curlpit\\Core\\Middleware\\BreakMiddleware",
        "Continue": "Curlpit\\Core\\Middleware\\ContinueMiddleware",
        "Noop":     "Curlpit\\Core\\Middleware\\NoopMiddleware",
        "Halt":     "Curlpit\\Core\\Middleware\\HaltMiddleware"
    },
    "middleware": [
        { "Http": { "url": "https://api.example.com/posts" } }
    ]
}

Token interpolation

All middleware that accepts string values supports token interpolation via Interpolator:

Token Resolves to
{attr:key} Request attribute
{attr:key.nested.path} Dot-notation into array attribute
{context:key} LoopContext value
{env:VAR_NAME} Environment variable (.env via phpdotenv)
"url":     "https://api.example.com/users/{attr:current_post.userId}",
"headers": { "x-api-key": "{env:MY_API_KEY}" }

Custom token types can be added by extending Curlpit\Core\Interpolator::resolveToken().

Environment-aware configuration

The {env:...} token makes the same middleware.json work across environments without any PHP code changes – only .env differs:

# .env.dev
PAYMENTS_API_URL=https://sandbox.stripe.com

# .env.prod
PAYMENTS_API_URL=https://api.stripe.com
"url": "{env:PAYMENTS_API_URL}/transactions/{attr:order.id}"

Condition DSL

{ "type": "always" }
{ "type": "never" }
{ "type": "attr",    "name": "status",   "eq":  "active" }
{ "type": "attr",    "name": "retries",  "lte": 3        }
{ "type": "context", "name": "has_more"                  }
{ "type": "context", "name": "count",    "gt":  0        }

Operators: eq, neq, gt, gte, lt, lte. Without an operator, truthy check.

Built-in middleware

Core


HttpMiddleware example

{
    "Curlpit\\Core\\Middleware\\HttpMiddleware": {
        "url":               "https://api.example.com/posts/{attr:current_post.id}/comments",
        "method":            "GET",
        "headers":           { "Accept": "application/json", "x-api-key": "{env:API_KEY}" },
        "store_response_as": "comments_raw",
        "store_status_as":   "comments_status",
        "timeout":           10
    }
}

MapMiddleware + CollectMiddleware example

{
    "Curlpit\\Core\\Middleware\\MapMiddleware": {
        "source_attr":      "posts_raw",
        "source_path":      "data",
        "item_attr":        "current_post",
        "result_attr":      "final_results",
        "accumulator_attr": "__map_posts",
        "middleware": [
            {
                "Curlpit\\Core\\Middleware\\HttpMiddleware": {
                    "url":               "https://api.example.com/users/{attr:current_post.userId}",
                    "store_response_as": "user_raw",
                    "store_status_as":   "user_status"
                }
            },
            {
                "Curlpit\\Core\\Middleware\\IfMiddleware": {
                    "condition": { "type": "attr", "name": "user_status", "eq": 404 },
                    "then": [ { "Curlpit\\Core\\Middleware\\ContinueMiddleware": {} } ]
                }
            },
            {
                "Curlpit\\Core\\Middleware\\CollectMiddleware": {
                    "accumulator_attr": "__map_posts",
                    "template": {
                        "post": "{attr:current_post}",
                        "user": "{attr:user_raw}"
                    }
                }
            }
        ]
    }
}

IfMiddleware example

{
    "Curlpit\\Core\\Middleware\\IfMiddleware": {
        "condition": { "type": "attr", "name": "api_status", "eq": 503 },
        "then": [
            { "Curlpit\\Core\\Middleware\\HaltMiddleware": { "status_code": 503 } }
        ],
        "else": [
            { "Curlpit\\Core\\Middleware\\NoopMiddleware": {} }
        ]
    }
}

Using third-party PSR-15 middleware

Any PSR-15 compliant middleware works with Curlpit without modification or wrappers – including inside loops, maps, and try bodies. Curlpit’s flow control state (__pc, __jump_to, __flow_signal) travels in request attributes and is invisible to third-party middleware.

Static wiring (explicit)

For middleware with complex constructor dependencies, wire them up in instantiate():

protected function instantiate(string $class, array $options): MiddlewareInterface
{
    return match ($class) {
        \Middlewares\AccessLog::class => new AccessLog($this->buildLogger()),
        default                       => parent::instantiate($class, $options),
    };
}

Declarative autowire

Curlpit supports fully declarative middleware configuration via middleware.json.

For simpler cases, there is no need to register or wire anything in your application code. All dependencies, configuration values, and even method calls can be defined in a single place.

Constructor parameter names must match the keys in middleware.json – snake_case is the convention for autowire-compatible middleware (e.g. store_response_as maps directly to the JSON key).

Note: Declarative autowire is experimental. The API may change in future versions.


Middlewares/AccessLog + Monolog

composer require middlewares/access-log monolog/monolog
{
  "middleware": [
    {
      "Middlewares\\AccessLog": {
        "autowire": {
          "logger": {
            "class": "Monolog\\Logger",
            "args": [
              "access",
              [
                {
                  "class": "Monolog\\Handler\\StreamHandler",
                  "args": ["../logs/access.log", 200]
                }
              ]
            ]
          }
        }
      }
    }
  ]
}

Middlewares/BasicAuthentication

composer require middlewares/http-authentication

Generate a password hash – the middleware uses password_verify() internally, so plain text passwords will never match. The hash is safe to store in middleware.json: even if the file is exposed, it cannot be reversed.

php -r "echo password_hash('your_password', PASSWORD_DEFAULT);"
{
  "middleware": [
    {
      "Middlewares\\BasicAuthentication": {
        "autowire": {
          "users": { "admin": "$2y$12$abc123..." }
        },
        "calls": [["verifyHash", []]]
      }
    }
  ]
}

PSR-11 container integration

This is an optional integration. Most projects will not need it.

For projects where middleware share services (loggers, database connections, etc.) and a PSR-11 container is already in use, Curlpit provides ContainerApplication:

use Curlpit\App\ContainerApplication;

$app      = new ContainerApplication($responseFactory, $streamFactory, $container);
$response = $app->handle($serverRequest);
(new Emitter())->emit($response);

Resolution order

  1. PSR-11 container – if the container knows the class, it resolves it
  2. Declarative autowire – fallback for classes the container doesn’t know

Complete example

See middleware.example.json for a full pipeline demonstrating all built-in middleware working together: multi-API aggregation across 3 domains, conditional branching, iteration flow control with break/continue, environment-based auth, and graceful error handling with halt.

Example project

DBCommander – a Norton Commander-style MySQL manager built on Curlpit.

License

MIT