Search by

The typed, long-running, API-first PHP framework for SaaS backends.

dev-main / 0.3.x-dev 2026-10-29 13:10 UTC

This package is auto-updated.

Last update: 2026-10-29 13:10:58 UTC


README

The typed, long-running, API-first PHP framework for SaaS backends.

Keel boots once and serves many requests (FrankenPHP, RoadRunner, FPM as fallback). It compiles routes, the container, validation and serialization into plain PHP before deploy, so the hot path has no reflection. Types are the contract: one input class is the validation, the documentation and the API schema.

Documentation: https://el-hendawy.github.io/keel/, with a tutorial that builds a multi-tenant API from scratch. Read the manifesto for the why.

#[Post('/orders')]
#[Status(201)]
final class CreateOrder
{
    public function __invoke(CreateOrderInput $in, Orders $orders): OrderResource
    {
        return OrderResource::from($orders->create($in));
    }
}

final readonly class CreateOrderInput
{
    public function __construct(
        #[Uuid] public string $merchantId,
        /** @var list<LineItem> */
        #[Count(min: 1)] public array $items,
        public ?string $note = null,
    ) {}
}

Send {"merchantID": "x", "items": []} and you get the following, with no validation code written:

{
  "type": "about:blank", "title": "Validation failed", "status": 422,
  "errors": [
    {"path": "merchantID", "code": "unknown_field", "message": "Unknown field."},
    {"path": "merchantId", "code": "required", "message": "This field is required."},
    {"path": "items", "code": "count", "message": "Must contain at least 1 item(s)."}
  ],
  "requestId": "01a18306-3f1c-7a86-9f61-44ac20443455"
}

Status: v0.3 (developer preview)

v0.1 delivered the core: router, compiled container, typed I/O, worker runtime and production defaults. v0.2 adds what a SaaS backend needs next: authentication with client-bound tokens, enforced tenancy, background jobs with a transactional outbox, a scheduler, caching, webhooks, pagination and a TypeScript SDK, plus a compiled ORM for every PDO database, TOON as a wire format, and a native FIX engine. v0.3 adds keel run, Keel's own server: many requests per process on an event loop, non-blocking PostgreSQL and Redis, WebSockets and server-sent events. It also adds OpenID Connect with key rotation, DPoP proof-of-possession tokens, broker transports (Redis Streams, SQS, RabbitMQ), and a PHPStan extension that checks ORM queries, and then encryption at rest and end to end, money and a hash-chained ledger, microservice plumbing (service identity, deadlines, CloudEvents with outbox and inbox, Kubernetes manifests), drivers for MongoDB, DynamoDB, Elasticsearch/OpenSearch, CouchDB, Neo4j and InfluxDB, and numeric vectors with an optional Rust extension that runs them with SIMD. Everything below is implemented and tested; items marked planned are on the roadmap.

Area What you get
Runtime keel run: Keel's server, many concurrent requests per process (a fiber per connection), multi-process with supervision and graceful drain, shared in-process state (guide). Also FrankenPHP worker mode, RoadRunner and FPM.
Real time WebSockets (RFC 6455) and server-sent events from ordinary endpoints with the same auth; a pub/sub hub across processes and nodes through Redis (guide)
Container Compiled to plain PHP; Singleton / Scoped / Transient lifetimes; a Singleton depending on a Scoped service (directly or through Transients) is a build error; cycles and missing bindings reported with a fix
Routing Attribute routes on invokable classes, static map plus combined regexes, typed path parameters (int only matches digits), 404/405 with Allow, named routes
Typed I/O Generated hydrators: strict types, unknown fields rejected, every violation with its path (items[0].sku); query strings coerced; outputs serialized from public properties; arrays are rejected as contracts at build time
Validation Uuid, Email, Url, Length, Count, Range, Pattern, NotBlank, Decimal, CurrencyCode, Iban, Bic, CardNumber, backed enums, RFC 3339 dates; custom rules compile to inline PHP; constructors can enforce cross-field rules
Encryption AES-256-GCM Encrypter with a key ring and rotation; #[Encrypted] entity columns; end-to-end encrypted request and response bodies (JWE, ECDH-ES + A256GCM, interoperable with JOSE libraries) with ->withMessageEncryption() (guide)
Fintech Exact Money (ISO 4217 scales, seven rounding modes, lossless allocation) usable as an API type; a double-entry ledger with idempotent postings, overdraft limits, reversals and a verifiable hash chain (guide)
Microservices ->withServices(): calls by service name with service tokens (JWT or OAuth client credentials), tenant, trace, request id and deadline propagation; CloudEvents through the outbox and an exactly-once Inbox; keel deploy:docker and deploy:kubernetes (guide)
Non-relational MongoDB (wire protocol, SCRAM, Decimal128), DynamoDB, Elasticsearch/OpenSearch, CouchDB, Neo4j and InfluxDB in PHP, non-blocking under keel run; one DocumentStore interface (guide)
Numeric vectors IntVector and FloatVector: exact, overflow-checked integer arithmetic, fixed-order float sums (identical on every machine), comparisons that return indices, gather, sort, unique; usable as API types. The optional keel_vector extension (Rust, SIMD chosen at run time) runs them 5–90× faster than plain arrays in a third of the memory; without it, a pure-PHP implementation gives the same results (guide)
Wire formats JSON, and TOON (Token-Oriented Object Notation) with ->withToon(): negotiated by Accept, accepted as request bodies, listed in OpenAPI
FIX Native FIX engine (keel fix:run): acceptor and initiator sessions, logon, sequence recovery with resend and gap fill, heartbeats, rejects; typed messages and repeating groups compiled at build time; handlers per message type
OpenAPI and SDK OpenAPI 3.1 generated from the same types, including security; keel sdk writes a typed, dependency-free TypeScript client
Modules #[Module(exports, dependsOn)]; using a non-exported class, or a module you do not depend on, fails the build; dependency cycles rejected
Concurrency Fibers with structured concurrency: await_all() with timeouts and cancellation; non-blocking HTTP client (curl_multi), non-blocking PostgreSQL (pgsql-async: DSN, pooled per request) and Redis pub/sub
HTTP client PSR-18, timeouts always set, per-host circuit breaker and bulkhead, trace propagation, RED metrics
ORM Compiled data mapper (guide): plain typed entities, no lazy loading (unloaded relations throw), queries checked by PHPStan (names, value types, operators), identity map with dirty-column updates, optimistic locking, automatic tenant scoping, soft deletes, timestamps, whereHas, eager loading in one query per relation, keyset cursorPaginate(), lazy() streaming, upserts, pivots, schema generation and forward-only diff. Dialects for SQLite, PostgreSQL, MySQL/MariaDB, SQL Server, Oracle and standard SQL for other PDO drivers
Database Parameterised SQL with literal-string enforcement, typed Rows, read/write splitting with read-your-writes, statement timeouts, result-size cap, savepoints, prepared-statement cache, N+1 detection (fails tests), forward-only migrations. Tested on SQLite, PostgreSQL and MySQL
Auth ->withAuth(): deny by default (#[Anonymous] opts out), #[Authorize(scopes, policy)] compiled into endpoints, Principal injection, hashed opaque tokens (keel_...) and JWTs (HS/RS/ES, no algorithm confusion). Tokens are bound to the client's fingerprint by default: a stolen token used from another client is refused and revoked. OpenID Connect providers with JWKS key rotation, the PKCE login flow, and DPoP proof-of-possession tokens (guide)
Tenancy Tenant from the principal, never from client input. The tenant guard refuses SQL on tenant tables that does not filter by the current tenant; acrossTenants() is the named exception; PostgreSQL row-level security support
Background work ->withQueue(): typed jobs with retries, backoff and failed-job tooling; transactional outbox (jobs commit with your data); async listeners; cron scheduler that runs each task once across nodes; distributed locks; broker transports: Redis Streams, Amazon SQS (SigV4, no SDK) and RabbitMQ, fed by the outbox
Webhooks Signed (Standard Webhooks), queued with retries, SSRF-protected against private networks
Cache and pagination In-memory LRU or Redis cache with stampede protection; signed keyset cursors
Observability OpenTelemetry-style traces (W3C traceparent, OTLP/HTTP export), Prometheus RED metrics per route, JSON logs with trace ids, /health/live, /health/ready
Safety defaults Rate limiting, idempotency keys, load shedding, body limits, security headers, trustProxy() named as unsafe, problem+json errors, production boot checks (debug, local-only stores, public metrics, missing app.key)
Testing In-process TestApp; frozen clock, seeded randomness, sequential ids and a fake HTTP client are bound automatically in test; tokenFor(), runJobs(), runSchedule(); the same tests run on SQLite, PostgreSQL or MySQL (KEEL_TEST_DATABASE_DSN)
Tooling keel new, build, routes, inspect (JSON for agents), openapi, sdk, check, migrate, serve, make:endpoint, make:migration, token:*, key:generate, queue:*, schedule:*, crypto:*, deploy:*, vector:info (reference)
Planned Non-blocking MySQL, gRPC, WebSocket compression, typed JSON columns, tenant connection routing, FIX over TLS, Cassandra (CQL), MongoDB transactions

Quality bar, enforced in CI: PHPStan level max with zero errors on the framework, the example application and the generated code. The vector extension is built and tested on Linux x86_64 and aarch64 (PHP 8.3 to 8.5, and thread-safe PHP) and macOS, linted with clippy and run under valgrind.

Quick start

Keel is published on Packagist as el-hendawy/keel (namespace Keel\). Install the keel command once, then create a project:

composer global require el-hendawy/keel
keel new acme && cd acme && composer install   # an app with a typed endpoint, a test and AGENTS.md
keel serve                                     # http://127.0.0.1:8000/hello/world
keel run                                       # the Keel server
vendor/bin/phpunit && vendor/bin/phpstan analyse

Inside a project keel runs the project's own version. Or add Keel to an existing project with composer require el-hendawy/keel:^0.3@dev.

To try the example application in this repository:

cd examples/shop
keel migrate
keel token:issue --principal=ada --tenant=11111111-1111-4111-8111-111111111111 --scopes='orders:*,invoices:read'
keel serve       # or: keel run, for the WebSocket and SSE endpoints

Then, in another terminal, with the token it printed:

TOKEN=keel_...
curl -X POST localhost:8000/orders -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"merchantId":"22222222-2222-4222-8222-222222222222","items":[{"sku":"TEA","quantity":1,"unitPriceCents":300}]}'
curl localhost:8000/orders -H "Authorization: Bearer $TOKEN"
keel queue:work --once   # runs the async listener that notifies the merchant

The token is bound to the first client that uses it (here, curl). Send it with a different User-Agent and it is refused and revoked.

In production, build during deploy and run workers:

KEEL_ENV=prod keel build --env=prod
KEEL_ENV=prod keel check --env=prod   # refuses single-node or leaky settings
KEEL_ENV=prod keel migrate
keel run --host=0.0.0.0 --port=8080             # or: frankenphp run (worker mode)
KEEL_ENV=prod keel queue:work         # and schedule:work, as separate processes

A tour in five files

Concept Example
Application shape (build time) examples/shop/app.php
Runtime configuration (boot time) examples/shop/config.php
Endpoint, input, output CreateOrder.php, CreateOrderInput.php, OrderResource.php
Module boundary + events across modules BillingModule.php, CreateInvoiceWhenOrderPlaced.php
Concurrent I/O GetOrderSummary.php
Auth, the principal, sign-out Me.php, RevokeCurrentToken.php
Live orders: WebSocket and server-sent events OrdersWebSocket.php, OrdersSocket.php, LiveOrders.php
Async listener + webhook (outbox) NotifyMerchantOfOrder.php
Cursor pagination ListOrders.php
Scheduled, deliberately cross-tenant task ReportDailyOrders.php
ORM entity, cursor pages, optimistic locking Product.php, ListProducts.php, UpdateProductPrice.php
FIX venue: typed messages and a handler examples/fix-gateway
End-to-end tests tests/Http/ShopTest.php

Documentation

The site at https://el-hendawy.github.io/keel/ is built from docs/ on every push to main (mkdocs build --strict; preview locally with pip install -r requirements-docs.txt && mkdocs serve).

Performance

Keel's server with the non-blocking PostgreSQL driver, against FrankenPHP workers, on an endpoint that waits 10 ms on the database (method and caveats):

Server /health/live /db/10, 512 connections Memory
FrankenPHP, 64 workers (PDO) 51,985 req/s (8 workers) 5,734 req/s 212 MB
keel run, 8 processes, pool 24 (async) 100,478 req/s 17,027 req/s 244 MB

In-process kernel throughput:

php -d opcache.enable_cli=1 benchmarks/kernel.php, one worker, in-process (no network), PHP 8.3, Apple silicon. Since v0.2 the shop requires authentication, so every request but the health check carries a bearer token that is looked up and checked against the client's fingerprint:

Scenario v0.2 req/s µs/req v0.1 req/s (no auth)
GET /health/live (full default middleware stack) 51,600 19.4 55,370
GET /orders/{id}, not found (auth, dynamic route, SQL read) 23,400 42.7 33,290
POST /orders, 422 (auth, JSON parse + validation) 29,700 33.6 44,301
POST /orders, 201 (auth, validation, transaction, event, outbox job, serialization) 13,350 74.9 18,921

Authentication with token binding costs about 11 µs per request (a token lookup by hash plus three HMACs); the created order now also writes an outbox job. Peak memory after 13,000 requests: 4 MB. These numbers measure framework overhead only. For end-to-end numbers against Laravel Octane, see benchmarks/vs-laravel (measured on v0.1).

Numeric vectors at 10 million elements, Apple M4, JIT off (all sizes, the JIT and the method):

Operation PHP array Pure-PHP vector keel_vector extension
Float sum (array_sum) 14.7 ms 101 ms 1.15 ms
Dot product (foreach) 131 ms 162 ms 2.3 ms
Sort (sort) 1.34 s 1.63 s 176 ms
Memory 256 MiB 256 MiB 76 MiB

Requirements

PHP 8.3+ (ext-json, ext-mbstring; ext-curl for the HTTP client, ext-pcntl for graceful shutdown, ext-pdo plus the driver for your database, ext-redis for shared stores). The keel_vector extension is optional and needs Rust to build (Linux and macOS; build steps). Following the support policy, v1.0 will require the current and previous PHP release.

License

MIT. The core stays MIT forever.