Skip to content

pushinbr/pam-http

pushinbr/pam-http is PAM’s optional HTTP application layer. It combines Express-like route ergonomics with Laravel-style application boundaries while keeping the persistent PAM runtime explicit and safe.

Every PAM product runs on the PAM runtime. Install the runtime once, verify the machine, create the project, and then use PAM’s Composer passthrough to add the HTTP package:

Terminal window
curl -fsSL https://github.com/push-in/pam/releases/latest/download/install.sh | sh
pam doctor
pam init my-api --template http
cd my-api
pam composer require pushinbr/pam-http
pam dev
<?php
declare(strict_types=1);
use Pam\App;
$app = new App();
$app->get('/users/{id}', static fn ($request, $response) =>
$response->json([
'id' => $request->route('id'),
])
);
$app->post('/users', static function ($request, $response) {
$payload = $request->json();
return $response->json([
'name' => $payload['name'] ?? null,
], 201);
});
$app->listen(3000);

Routes accept closures, invokable controllers, or an explicit controller and method pair:

$app->post('/orders', CreateOrderController::class);
$app->post('/login', [LoginController::class, 'onLogin']);

Class-and-method handlers are checked when the route is registered. PAM resolves the controller through its container, injects constructor services, and injects request, response, container services, and named route parameters into the action method.

final readonly class LoginController
{
public function __construct(private LoginService $login) {}
public function onLogin(LoginRequest $request): AuthResource
{
return new AuthResource($this->login->handle($request->validated()));
}
}
use Pam\Api\RouteConstraint;
use Pam\Api\RouteRegistrar;
$app->prefix('/api/v1')
->middleware(Authenticate::class)
->group(function (RouteRegistrar $routes): void {
$routes->apiResource('/users', UserController::class);
$routes->get('/users/{id}', [UserController::class, 'show'])
->where('id', RouteConstraint::Integer)
->name('users.show');
});

Built-in constraints cover integers, UUIDs, ULIDs, slugs, alphabetic values and alphanumeric values. where() also accepts an explicit regular expression.

$container = $app->container();
$container->bind(UserRepository::class, DatabaseUserRepository::class);
$container->singleton(Cache::class, RedisCache::class);
$container->scoped(CurrentUser::class);

Transient bindings create a value for each resolution. Singletons live for the worker. Scoped values live only for the current request and are discarded in a finally boundary, including exceptional responses. Never place request or tenant state in a singleton.

use Pam\Api\Validation\FormRequest;
use Pam\Api\Validation\Rule;
final class LoginRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'type' => ['required', Rule::enum(UserType::class)],
];
}
}
enum UserType: int
{
case Regular = 1;
case Administrator = 2;
}

Validation and authorization stay outside controllers. Validation failures use Problem Details and stable integer error codes. Status/type/state/kind/category values should be sequential integer-backed enums.

Validated values can be hydrated into a readonly DTO, including backed enum constructor arguments:

$data = $request->dto(CreateUserData::class);

Handlers may return JsonResource or ResourceCollection. PAM converts them into consistent data and optional meta envelopes:

final readonly class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->resource->id,
'name' => $this->resource->name,
'type' => $this->resource->type->value,
];
}
}

Pass a valid HTTP status as the second constructor argument when the action creates a resource:

public function store(StoreUserRequest $request): UserResource
{
return new UserResource($this->users->create($request->data()), 201);
}

Codes outside 100..599 are rejected during construction. Collections retain the normal 200 status.

Convenience methods exist for GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS. Use route() for another valid HTTP method. When no explicit OPTIONS route exists, PAM returns 204 with a deterministic Allow header. Every GET route advertises and matches HEAD; explicit handlers take precedence. The final HEAD body is suppressed after middleware and error handling while status and headers are preserved.

Route paths must be absolute and cannot include a query string. A parameter occupies an entire segment:

$app->get('/teams/{team}/members/{member}', $handler);

Parameter names must be unique and match [A-Za-z_][A-Za-z0-9_]*. Static paths are normalized for a trailing slash. HEAD falls back to a matching GET route.

Route configuration has explicit resource limits: 10,000 routes by default (configurable up to 100,000), 2 KiB paths, 128 segments, 32 parameters and 512-byte custom constraints. Compiled patterns carry PCRE match/depth budgets; oversized request paths are rejected before regex matching.

  • A matching route receives decoded route parameters.
  • A path with no route returns Problem Details 404.
  • A known path with the wrong method returns Problem Details 405, code 9, and an Allow header.
  • Registering the same method and path twice throws during application configuration.

Middleware can implement PAM’s MiddlewareInterface or be a callable:

use Pam\Contracts\Http\RequestHandlerInterface;
use Pam\Http\Request;
use Pam\Http\Response;
$app->middleware(
static function (
Request $request,
Response $response,
RequestHandlerInterface $next,
): Response {
$response = $next->handle($request, $response);
return $response->header('x-runtime', 'pam');
},
);

The pipeline is compiled once, in registration order, when the application first handles work. Every middleware layer must return Pam\Http\Response.

Routing, operational and unexpected failures share one Problem Details contract with application/problem+json. The default boundary logs the full exception through PAM telemetry and returns only:

{"type":"https://pam.dev/problems/8","title":"Internal Server Error","status":500,"code":8}

It does not expose the exception message to the client. Domain details attached to HttpException may add fields but cannot override type, title, status, or code.

Customize the boundary before the application starts:

$app->onError(
static fn (Throwable $error, $response) =>
$response->json(['error' => 'Request failed'], 500),
);

The custom handler must return a PAM response.

Service providers implement register() and boot(). Registration happens while the application is mutable; providers boot when the route and middleware pipeline freezes.

Console tools can call the idempotent $app->boot() method to boot providers and freeze configuration without dispatching a synthetic HTTP request. The generated migration command uses this boundary.

Composer package discovery can register providers from installed package metadata. Pass discoverPackages: false to new App() when configuration must be fully explicit.

Eloquent is the official PAM API ORM. Its connection resolver creates one Illuminate manager per request Fiber and automatically rolls back unfinished transactions and disconnects connections at the request boundary.

use Pam\Api\Database\DatabaseConfig;
use Pam\Api\Database\EloquentServiceProvider;
$app->provider(new EloquentServiceProvider(DatabaseConfig::fromEnvironment()));

DB_CONNECTION accepts pgsql, mysql, sqlite, or sqlsrv; PostgreSQL is the production default. Models, events, schema builders and anonymous-class Laravel migrations work normally. MigrationManager supports migrate, rollback, named connections and dry-run execution.

Run schema changes as an explicit deployment step. The starter exposes pam composer migrate; it never races migrations across HTTP workers during application startup.

The generated pam-skeleton is the executable reference architecture: thin named controller methods, Form Request and DTO, service, repository abstraction, Eloquent implementation, migration, Resource, SQLite tests, and integer-backed domain status.

Use QueryBudgetMiddleware to fail tests or development requests that exceed query count, accumulated SQL time or duplicate-query limits. This turns N+1 detection into an executable route contract instead of a manual review.

For tenant-owned models, register TenantModelGuard during boot. It applies a global scope, assigns tenant_id on creation, rejects cross-tenant assignment and fails closed when no scoped TenantContext exists.

use Pam\Api\Config\ConfigDefinition;
use Pam\Api\Config\Configuration;
use Pam\Api\Config\ConfigType;
$config = Configuration::fromEnvironment([
new ConfigDefinition('app.port', 'PAM_PORT', ConfigType::Integer),
new ConfigDefinition('auth.secret', 'AUTH_SECRET', sensitive: true),
]);

Configuration is validated before the application serves requests. Diagnostics must use redacted(), which masks sensitive values. Error messages name the missing variable and expected type without echoing the supplied value.

HmacTokenCodec issues bounded HS256 access tokens with issuer, audience, iat, nbf, exp, jti and abilities. BearerTokenAuthenticator integrates the codec with AuthenticateMiddleware.

The active signing key has a bounded kid; up to four retiring keys may remain in the verification keyring for zero-downtime rotation. Unknown key IDs fail closed. A TokenRevocationStore can revoke a jti; the included memory store is bounded and intended only for development and tests. Production clusters must implement the contract with atomic shared state.

Access tokens cannot live longer than 24 hours. Refresh-token rotation and OAuth/OIDC authorization-server behavior still belong to an identity provider or a separately audited application flow.

$app->observe($observer) registers a RequestLifecycleObserver. Observers start in registration order and finish in reverse order before request-scoped state is destroyed. They receive the handled failure when present; an observer cleanup failure is logged and cannot stop the remaining cleanup chain.

MemoryProfiler is the bounded first PAM Lens collector. It is disabled by default and must be enabled with ProfilerMode::Development or ProfilerMode::Testing. Profiles retain only method, path, status, duration, memory delta and failure class. Headers, bearer tokens and request/response payloads are never stored.

Inside a PAM API project:

Terminal window
pam make:model User
pam make:controller UserController
pam make:request StoreUserRequest
pam make:resource UserResource
pam make:service CreateUserService
pam make:repository UserRepository
pam make:migration CreateUsersTable

Names must be bounded PascalCase identifiers. Generators create the expected application directories and refuse to overwrite existing files. Controllers use named actions and are ready for handlers such as [UserController::class, 'index'].

JobQueue defines an at-least-once lifecycle for durable adapters: bounded dispatch delay, reservation leases, acknowledgement, delayed release and dead-letter transitions. JobWorker runs the same contract against the bounded memory queue in development and tests. Expired leases may be reclaimed, failed attempts retry up to the envelope limit, and exhausted jobs move to the dead-letter set. Diagnostics retain the failure class, never its message.

Durable adapters encode SerializableJob values with JobCodec. Its JSON envelope is versioned, limited to 64 KiB and depth 16, and resolves job names through an explicit registry. It does not accept PHP serialized objects or client-controlled class names.

Routes, middleware, providers, error handlers, and PSR handlers cannot be changed after the application begins handling requests. PAM throws rather than silently mutating a live long-lived pipeline.

composer verify includes composer api:compat, which compares PAM API’s public classes, interfaces, enums, methods, parameters, properties and constants with the committed api-surface.json. Unreviewed additions fail as baseline drift. Removals, signature changes, new interface/abstract methods, newly final or abstract contracts and enum changes fail as incompatibilities; all findings use sequential integer codes.

Run bin/api-compat update after approving compatible additions. Updating the baseline for breaking changes is allowed only on an intentional major-version branch after its upgrade guide and changelog describe every break. Normal CI never regenerates the baseline.

The generated 118-symbol reference lists exact constructors, methods, parameters, return types, properties, constants and integer enum cases. It is generated from the same reviewed surface baseline used by the SemVer gate, and CI rejects stale output.

Type Public surface
App Route verbs, prefix(), group(), container(), middleware, providers, errors, listener and request handling
Router add(method, path, handler), match(method, path), routes()
RouteRegistrar Prefix/group composition and REST resource registration
PendingRoute Route names, constraints and route middleware
Container Transient, singleton and request-scoped dependency resolution
FormRequest Dedicated authorization and input validation
JsonResource Domain response transformation and data envelopes
Pipeline Construct with middleware and a destination; execute with handle()
CallableRequestHandler Adapts a callable to RequestHandlerInterface
Route Method, path, compiled handler, matching metadata and route middleware
RoutingResult Readonly type, matched route, decoded parameters and allowed methods
PackageDiscovery providers(projectRoot) reads Composer package provider metadata
EloquentManager Fiber-local connections, schemas and transactions
MigrationManager Laravel-compatible migration and rollback execution
Configuration Boot-time typed configuration and secret-safe diagnostics
HmacTokenCodec Signed access tokens, bounded key rotation and verification
RequestLifecycleObserver Failure-aware request start/finish instrumentation
MemoryProfiler Bounded privacy-safe development request profiles
JobQueue Lease, acknowledgement, retry and dead-letter adapter contract
JobWorker Bounded one-job execution with at-least-once semantics
JobCodec Allowlisted and bounded JSON serialization for durable jobs

RoutingResultType is integer-backed: Found=1, MethodNotAllowed=2, NotFound=3.

use Pam\Api\Middleware\CorsMiddleware;
use Pam\Api\Middleware\RateLimitMiddleware;
use Pam\Api\Middleware\SecurityHeadersMiddleware;
$app
->middleware(new CorsMiddleware(
origins: ['https://app.example.com'],
credentials: true,
maxAge: 600,
))
->middleware(new RateLimitMiddleware(
requestsPerSecond: 20,
burst: 40,
maxBuckets: 65_536,
idleTtlSeconds: 300,
))
->middleware(new SecurityHeadersMiddleware(hsts: true));

CorsMiddleware handles matching origins and terminates valid preflight requests with 204. Never combine wildcard origins with credentials. RateLimitMiddleware is a per-worker, in-memory token bucket keyed by REMOTE_ADDR by default. Pass a RateLimitStore backed by Redis or another atomic shared system when limits must be global across workers, and pass a custom key resolver for user, token or tenant limits. Proxy-derived client addresses must only be accepted behind an explicitly trusted proxy boundary. Limit, remaining and retry headers are emitted with a Problem Details 429. SecurityHeadersMiddleware adds content-type, frame, referrer and permissions policies; HSTS is added only when HTTPS=on.

Authentication uses application-provided Authenticator and Principal contracts. The authenticated principal and resolved tenant exist only in the current request scope. Ability checks never retain user state in a worker singleton.

IdempotencyMiddleware replays an identical request and rejects reuse of the same key with a different payload. Response caching and idempotency both use replaceable stores; bounded memory stores are included for development and tests, while multiworker deployments should provide atomic shared adapters.

$app->post('/users', [UserController::class, 'store'])
->name('users.store')
->summary('Create a user')
->tags(['Users'])
->input(StoreUserRequest::class)
->output(UserResource::class);
$contract = $app->openApi('My API', '1.0.0');
$json = $contract->toJson();
$typescript = $contract->client(ClientLanguage::TypeScript);

Kotlin and Swift clients use the same OpenAPI 3.1 contract. Compatibility checks report removed paths and operations through sequential integer codes.

composer benchmark emits schema 1 JSON for 100 routes plus one constrained dynamic route. It performs warm-up, measures static and dynamic paths independently, and reports throughput, p50/p95/p99 latency and peak memory. composer verify runs the assertion mode with conservative throughput, p99 and memory ceilings. These gates detect severe regressions; they are not portable marketing comparisons because host hardware and load topology still matter.

The package includes replaceable contracts for transactions, events, jobs, cache, retry, circuit breakers, deadlines, health checks, tenant resolution and request observations. SSE remains in PAM’s native response API; WebSocket rooms and events remain in pushinbr/pam-socket.

TestClient executes the real application pipeline without a network socket and provides status, header, JSON and JSON-path assertions. Container diagnostics expose request-scope state for leak checks.