el-hendawy / keel
The typed, long-running, API-first PHP framework for SaaS backends.
Requires
- php: >=8.3
- ext-json: *
- ext-mbstring: *
- nyholm/psr7: ^1.8
- psr/clock: ^1.0
- psr/container: ^2.0
- psr/event-dispatcher: ^1.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- psr/log: ^3.0
Requires (Dev)
- php-amqplib/php-amqplib: ^3.7
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
Suggests
- ext-curl: Required for the non-blocking HTTP client
- ext-pcntl: Graceful shutdown on SIGTERM in long-running workers
- ext-pdo: Database layer
- ext-pgsql: The non-blocking PostgreSQL driver (pgsql-async: DSNs) for keel run
- ext-redis: Shared stores for rate limiting and idempotency across nodes
- php-amqplib/php-amqplib: The RabbitMQ queue transport
- spiral/roadrunner-http: Run under RoadRunner
Provides
None
Conflicts
None
Replaces
None
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).
- Manifesto: principles, what we reject, scalability, promises, roadmap, governance
- Architecture: layers, the compiler, the request lifecycle, generated files
- Guides: getting started · endpoints and types · services and lifetimes · modules · auth and token binding · tenancy · background work · cache and pagination · ORM · the Keel server · real time · TOON · FIX · microservices · encryption · money and ledgers · non-relational databases · numeric vectors · concurrency · database · observability · production · testing · tooling
- Contributing · Security policy · RFC template · Changelog
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.