Skip to content

Getting started

Create a project

Requirements: PHP 8.3+ with ext-json and ext-mbstring, plus ext-pdo and the driver for your database, ext-curl for the HTTP client, ext-pcntl for graceful shutdown and ext-redis for shared stores. The keel_vector extension is optional: without it, numeric vectors run in PHP.

composer global require el-hendawy/keel     # once: installs the `keel` command
keel new acme                               # scaffold a project
cd acme && composer install
keel serve                                  # development server on http://127.0.0.1:8000

Make sure Composer's global bin directory is on your PATH (composer global config bin-dir --absolute). Inside a project, keel runs the project's own version.

Keel's Composer package is el-hendawy/keel (namespace Keel\). Until the first tagged release it is installed from main (^0.3@dev). To add Keel to an existing project: composer require el-hendawy/keel:^0.3@dev. The tutorial builds a complete API from here.

The skeleton contains:

Path Purpose
app.php The application's shape, compiled by keel build: what to scan, bindings, middleware
config.php Runtime values, read once per worker at boot. $env is the Environment
public/index.php The single entry point for FrankenPHP (worker or classic), RoadRunner and FPM
src/<Module>/ Your code, one directory per module
tests/ In-process tests with TestApp
Caddyfile FrankenPHP in worker mode
AGENTS.md Conventions for coding agents (and humans)

The development loop

In dev and test, the build refreshes itself when a source file changes. A long-running worker cannot reload classes it has already loaded, so run FrankenPHP with --watch (or restart RoadRunner workers) in development; keel serve runs one process per request and picks up every change.

vendor/bin/keel routes      # what is mounted where
vendor/bin/keel inspect     # everything the compiler knows, as JSON
vendor/bin/phpstan analyse  # level max, from day one
vendor/bin/phpunit

Your first endpoint

namespace App\Greeting;

use Keel\Routing\Attribute\Get;
use Psr\Clock\ClockInterface;

/** Greets someone by name. */
#[Get('/hello/{name}', name: 'greeting.hello')]
final class SayHello
{
    public function __invoke(string $name, ClockInterface $clock): Greeting
    {
        return new Greeting("Hello, $name!", $clock->now());
    }
}

final readonly class Greeting
{
    public function __construct(public string $message, public \DateTimeImmutable $at) {}
}

The docblock's first line becomes the OpenAPI summary. $name matches {name}; ClockInterface is a service, so it is injected; the return type is the response schema.

Next: endpoints and types.