Search by

linkfast / exengine-cli

gchiappe

exen: command line tool for the ExEngine microframework (new projects, dev server, generators, extensions).

Package info

gitlab.com/linkfast/oss/exengine-cli

Issues

pkg:composer/linkfast/exengine-cli

Statistics

Installs: 45

Dependents: 0

Suggesters: 0

Stars: 0

1.0.0-a9 2026-10-16 10:25 UTC

This package is auto-updated.

Last update: 2026-10-16 10:37:55 UTC


README

Command line tool for the ExEngine microframework: create projects, run the development server, generate code and manage ExEngine extensions.

© LinkFast S.A. (https://linkfast.io) · MIT License

Requirements

  • PHP 8.1 or higher
  • Composer
  • Optional: ext-yaml (pecl install yaml) so projects can use config/application.yml

exen has no Composer dependencies, so installing it globally does not conflict with other global tools.

Installation

composer global require linkfast/exengine-cli:^1.0.0-a1

exen 1.0.0 is in development and published as pre-releases (1.0.0-a1, 1.0.0-a2, ...); the version constraint lets Composer install them. Projects created with exen new require the pre-releases of ExEngine 1.3.0 and exen the same way.

Make sure Composer's global bin directory is on your PATH (composer global config bin-dir --absolute prints it). On Windows, Composer creates exen.bat there automatically.

Projects created with exen new also require exen as a dev dependency. When you run the global exen inside such a project, it hands over to the project's copy, so every project uses the exen version matching its dependencies.

Quick start

exen new orders-api
cd orders-api
exen serve

exen new asks for the project settings (package name, version, namespace, servers, ...); answer with Enter to keep the defaults. Open http://127.0.0.1:8080/Hello/world.

Commands

CommandDescription
exen new [<directory>]Create a project (interactive, or from flags) with the web server configurations you choose, and run composer install
exen new:extension [<directory>]Create an ExEngine extension package (settings, injectable service, tests), ready to publish
exen serveRun the development server the project is made for: RoadRunner or PHP's built-in server
exen contract:add <file>Add an OpenAPI, AsyncAPI, GraphQL or gRPC (.proto) contract and generate its server (--client for a client)
exen generateRegenerate the code of the API contracts (--check in CI)
exen docker:initCreate a Dockerfile, .dockerignore and compose.yaml for the project (--mode=multi or single)
exen rr:installDownload the RoadRunner server (rr) for this system into the project
exen rr:initCreate worker.php and .rr.yaml to run the project on RoadRunner
exen rr:jobsWrite the RoadRunner pipelines of the AsyncAPI contracts to .rr.yaml (--driver=memory, amqp, kafka...)
exen protoc:installDownload protoc (the protobuf compiler) into the project, for gRPC contracts
exen routesList the routes of the project controllers
exen doctorCheck PHP, extensions, Composer and the current project
exen make:controller <Name>Generate a controller (--rest for a REST controller, Admin/Users for a sub-folder)
exen make:component <Name>Generate an injectable component
exen make:filter <Name>Generate a request filter (--response for a response filter)
exen make:dataobject <Name>Generate a DataObject
exen make:listener <Name>Generate a lifecycle listener (--event=ResponseReady)
exen make:properties <Name>Generate a #[ConfigProperties] class (--prefix=database)
exen extension:add <name or vendor/package>Install an ExEngine extension (first-party by name: graphql, grpc, monolog, roadrunner) (composer require), activated automatically (--manual-activation to activate it yourself)
exen extension:listList the installed ExEngine extensions and how they are activated
exen extension:remove <name or vendor/package>Uninstall an ExEngine extension (composer remove)

exen help <command> shows the arguments and options of each command; exen list shows them all.

exen new

exen new [orders-api] [--package=acme/orders-api] [--description=...] [--project-version=0.1.0] [--namespace=App]
         [--api-type=rest,graphql,...] [--php=">=8.1"] [--server=apache,nginx,...] [--rr] [--config-format=yml|json|php]
         [--extension=vendor/package ...] [--no-install] [-n|--no-interaction]

In a terminal, exen new asks for every setting not given as a flag, shows a summary and asks for confirmation. It does not ask anything when --server or --rr is given, with -n / --no-interaction, or without a terminal (CI, scripts); missing settings then take their defaults (package app/<directory>, version 0.1.0, namespace App, a REST API on the built-in server).

API types (--api-type, comma-separated, or chosen from the list), kept in composer.json (extra.exengine.api):

API type
restHTTP REST: controllers with automatic routing and JSON responses
openapiContract-first REST: server and client generated from OpenAPI 3 documents (--api, see below)
graphqlSchema-first GraphQL with linkfast/exengine-graphql: server and clients generated from SDL (--api schema.graphql, see below)
grpcServices and clients generated from .proto files, served by RoadRunner (--api orders.proto, see below); adds RoadRunner to the servers
asyncapiMessage handlers and producers generated from AsyncAPI documents, on RoadRunner jobs (--api events.yml, see below); adds RoadRunner to the servers

Servers (--server, comma-separated, or chosen from the list):

ServerGenerated files
builtinNothing: PHP's built-in server through exen serve, for development
apachepublic/.htaccess (mod_rewrite front controller)
nginxdeploy/nginx.conf (server block for PHP-FPM)
caddydeploy/Caddyfile (php_fastcgi to PHP-FPM)
frankenphpdeploy/frankenphp/Caddyfile and public/frankenphp-worker.php (worker mode)
roadrunner (rr)worker.php, .rr.yaml; installs linkfast/exengine-roadrunner and the rr binary, PHP 8.2+

All of them route URLs without the launcher (/Hello/world, which needs linkfast/exengine 1.3.0-a1+); the classic /api.php/Hello/world keeps working. --extension (repeatable) installs extensions as exen extension:add does.

composer.json records the development server in extra.exengine.server (roadrunner when it is among the servers, builtin otherwise), which exen serve runs. RoadRunner projects also get the worker pool per profile: roadrunner.workers: 2 in application.*, and one worker per CPU core with max-jobs: 500 in application-prod.*.

Creates:

PathContents
public/api.phpLauncher; the web server document root is public/
src/AppConfig.phpRegistration of components, filters, listeners and properties classes
src/Controllers/Hello.phpSample controller: /Hello/world
config/application.ymlProperties, plus application-prod.yml for the prod profile
composer.jsonRequires linkfast/exengine; extra.exengine describes the layout for exen

The configuration format defaults to YAML when ext-yaml is loaded, JSON otherwise.

exen new:extension

exen new:extension [exengine-metrics] [--package=acme/exengine-metrics] [--name=metrics] [--namespace=Acme\Metrics]
                   [--description=...] [--php=">=8.1"] [--author=...] [--no-install] [-n|--no-interaction]

Creates a Composer package of type exengine-extension, so applications install it with exen extension:add and ExEngine activates it at boot. In a terminal it asks for the settings not given as flags (--package or -n skip the questions). The name (by default the directory without exengine-) gives the class names and the settings prefix:

PathContents
src/MetricsExtension.phpExtension::register(): binds the settings and registers the service
src/MetricsProperties.php#[ConfigProperties('metrics')] settings with their defaults (metrics.enabled)
src/MetricsService.phpA component applications inject
tests/Boots ExEngine with the extension and checks the settings and the service (vendor/bin/phpunit)
composer.jsonRequires linkfast/exengine; extra.exengine.extension names the extension class
README.md, CHANGELOG.md, LICENSEReady to publish (MIT)

To try it in an application before publishing, add the folder as a Composer path repository there and run exen extension:add acme/exengine-metrics:@dev (the generated README shows the commands).

exen serve

exen serve [--host=127.0.0.1] [--port=8080] [--profile=prod] [--rr | --builtin]

Runs the server the project is made for: RoadRunner when composer.json says so (extra.exengine.server: roadrunner, set by exen new), when the project has AsyncAPI consumers, or when it has worker.php and .rr.yaml with the RoadRunner extension installed; PHP's built-in server otherwise. --rr and --builtin choose one explicitly. --profile sets EXENGINE_PROFILES for the application.

PHP's built-in server serves the public/ folder; code changes apply on the next request. Existing files of public/ are served as they are and every other URL goes to the launcher, so both /Hello/world and /api.php/Hello/world work.

RoadRunner

RoadRunner runs ExEngine in long-running PHP workers: the application boots once and each worker answers many requests. Set a project up with:

exen extension:add roadrunner                     # the worker (needs PHP 8.2+ and ext-sockets)
exen rr:install                                   # downloads rr 3.0.0 for this system (--version to choose)
exen rr:init                                      # writes worker.php and .rr.yaml
exen serve --rr                                   # http://127.0.0.1:8080/Hello/world

or create a new project with exen new orders-api --rr, which does all of it (and makes exen serve use RoadRunner without --rr).

On RoadRunner, exen serve [--host] [--port] [--profile] [--workers=N] [--no-watch] runs rr serve with the project's .rr.yaml, overriding for development the address, the PHP binary and the log format, and sizing the worker pool with the roadrunner.workers, roadrunner.max-jobs and roadrunner.max-memory properties of the active profiles (--workers wins; 2 workers when nothing is set). Because workers keep the application in memory, exen watches src/, config/ and worker.php and resets the workers (rr reset) when a file changes; --no-watch turns this off.

exen rr:init writes the same pool settings to .rr.yaml, taken from the prod profile (--profile to choose), since rr serve uses that file in production; run exen rr:init --force again after changing them. Components marked #[Service] live as long as their worker (see the extension's README).

On RoadRunner, URLs work with and without the launcher (/api.php/Hello/world and /Hello/world), files in public/ are served as static files and PHP files there are never executed. For production run rr serve with your .rr.yaml (exen doctor checks the setup).

rr:install extracts the release archive with PHP's bundled phar support (no ext-zip needed) and adds the binary to .gitignore.

Docker

exen docker:init                          # asks for the server and the mode in a terminal
exen docker:init --mode=multi             # multi-stage: composer install inside the build (CI/CD)
exen docker:init --mode=single            # single stage: copies vendor/ (hybrid pipeline, quick in development)
exen docker:init --server=nginx --php=8.3 --force
exen new shop --docker=multi              # or answer the Dockerfile question of exen new

The image is made for the server the project is set up for (its configuration files decide; Apache when it has none, writing public/.htaccess):

ServerImageRun
roadrunnerphp:cli with the official rr binaryrr serve -c .rr.yaml, port 8080
frankenphpdunglas/frankenphp, worker modedeploy/frankenphp/Caddyfile, port 8080
nginx, caddyphp:fpm (app) and nginx/caddy (web) targetsdocker compose up, with docker/nginx.conf or docker/Caddyfile
apachephp:apache with mod_rewrite, document root public/port 80

The image installs the PHP extensions the project needs with install-php-extensions: the ext-* of composer.json and of the installed packages, yaml for application.yml, sockets for RoadRunner, and opcache; it uses php.ini-production and the prod profile (EXENGINE_PROFILES=prod).

  • Multi-stage (multi, default): a build stage runs composer install --no-dev from composer.json and composer.lock, so the image comes from the sources alone; .dockerignore leaves vendor/ out.
  • Single stage (single): the project is copied as it is, vendor/ included; the pipeline (or you) runs composer install --no-dev --optimize-autoloader before docker build.

API contracts (OpenAPI)

exen generates PHP code from OpenAPI 3.0 and 3.1 contracts (YAML or JSON): the server of an API your project implements, and clients of the APIs it calls, for example from a Backend for Frontend. Everything is generated while developing; nothing reads the contract at runtime.

exen new orders-api --api orders.yml          # a new project implementing a contract
exen contract:add orders.yml                  # server: this project implements the API
exen contract:add payments.yml --client       # client: this project calls the API
exen contract:add https://petstore3.swagger.io/api/v3/openapi.json --client   # a published contract
exen generate                                 # after editing a contract
exen generate --check                         # in CI: fails if the generated code is out of date

Contracts are copied to contracts/ (with the files they reference through $ref) and listed in composer.json (extra.exengine.contracts). A URL is downloaded, with the files of its relative $ref, into contracts/<name>/; composer.json keeps the URL as source, and a generic file name (openapi.json) takes its name from the API title (swagger-petstore). For a contract named orders:

PathContents
src/Generated/Owned by exen, rewritten by exen generate: commit it, do not edit it
src/Generated/Orders/Model/Models: readonly classes with fromArray() (validation that reports every problem with its JSON Pointer), toArray() and jsonSerialize(), and backed enums
src/Generated/Orders/Api/OrdersApi.phpOne interface per tag: the operations, with typed parameters
src/Generated/Orders/Response/One class per operation, with a factory per declared response: GetOrderResponse::ok(Order), ::notFound(Error)
src/Generated/Orders/Http/Controllers: read and validate the request, call your implementation
src/Generated/Orders/Client/OrdersClient.phpClients (--client)
src/Generated/Orders/OrdersContract.phpExtension that registers the routes and binds each interface to its implementation
src/Orders/OrdersService.phpYours: the implementation, created once with operations that answer 501, never overwritten
final class OrdersService extends Component implements OrdersApi
{
    public function __construct(private readonly OrderRepository $orders)   // components can be injected
    {
    }

    public function getOrder(string $id): Response\GetOrderResponse
    {
        $order = $this->orders->find($id);
        return $order === null
            ? Response\GetOrderResponse::notFound(new Model\Error(404, "Order $id not found"))
            : Response\GetOrderResponse::ok($order);
    }
}
  • Requests that do not match the contract get 400 RFC 9457 Problem Details (application/problem+json), each error naming the parameter or pointing into the body ("pointer": "#/lines/0/quantity"). Throw ApiException for an error the contract does not declare.
  • When a contract gains operations, exen generate shows the methods to add to your implementation.
  • AppConfig activates the contracts with Generated/contracts.php (exen adds the lines once).
  • The server uses the explicit routes of linkfast/exengine 1.3.0-a3 or higher; the route paths include the base path of the contract's first server (/v1).

Clients return the success body as a model and throw ResponseException for error responses (body() is the model the contract declares for that status). They send requests with CurlTransport (ext-curl, no dependencies) or Psr18Transport (any PSR-18 client). Batch::run() runs calls in parallel, as a BFF needs:

$orders = new OrdersClient(OrdersClient::DEFAULT_URL, headers: ['Authorization' => "Bearer $token"]);
['order' => $order, 'payments' => $payments] = Batch::run([
    'order' => fn() => $orders->getOrder($id),
    'payments' => fn() => $paymentsClient->listPayments(orderId: $id),
]);

Supported: objects, arrays, maps (additionalProperties), enums, nullable (3.0 nullable and 3.1 type lists), allOf, defaults and validation keywords (minLength, pattern, format, minimum, minItems...); path, query and header parameters; JSON bodies. Not yet: oneOf/anyOf (generated as mixed, with a warning), multipart bodies, cookie parameters, webhooks and callbacks.

API contracts (GraphQL)

exen generates PHP code from GraphQL schemas (SDL) for the linkfast/exengine-graphql extension: the server of the schema your project serves, and clients of GraphQL APIs it calls. exen reads the schema with the project's webonyx/graphql-php, which the extension brings (exen extension:add graphql; for clients only, composer require --dev webonyx/graphql-php is enough).

exen new shop --api schema.graphql                                  # a new project serving a schema
exen contract:add schema.graphql --name shop                        # server
exen contract:add schema.graphql --name shop --operations contracts/shop   # server, with trusted documents
exen contract:add https://api.example.com/graphql --client --operations queries   # client of an endpoint
exen contract:add https://example.com/schema.graphql --client --endpoint https://example.com/graphql

A URL ending in /graphql (or any URL with --introspect) is read with an introspection query, and becomes the client's endpoint. --operations is a folder of the project with operation documents (.graphql): the operations of the client, and the trusted documents of the server (graphql.trusted-documents). They are validated against the schema when generating. For a schema named shop:

PathContents
src/Generated/Shop/Model/Enums (backed enums), input types (readonly classes), object types (readonly classes with the fields you do not resolve), interfaces and unions (PHP interfaces)
src/Generated/Shop/Api/Per type with fields you resolve, an interface: QueryApi, MutationApi, OrderApi
src/Generated/Shop/ShopSchema.php, schema.phpThe schema for the extension, precompiled: nothing is parsed at runtime
src/Generated/Shop/Client/Client (--client): ShopClient with a method per operation, and per operation its result classes with only the selected fields
src/Shop/QueryResolver.phpYours: the resolvers, created once (they fail with NOT_IMPLEMENTED until written), never overwritten

You resolve every field of Query and Mutation, and the fields of other types that take arguments or are marked @resolve (exen declares the directive); the other fields are read from the models your resolvers return:

type Order {
  id: ID!
  total: Float!
  customer: Customer @resolve      # OrderResolver::customer(Model\Order $order)
  lines(first: Int = 10): [Line!]! # OrderResolver::lines(Model\Order $order, int $first)
}
final class QueryResolver extends Component implements QueryApi
{
    public function __construct(private readonly OrderRepository $orders)   // components can be injected
    {
    }

    public function order(string $id): ?Model\Order
    {
        return $this->orders->find($id) ?? throw new UserError("Order $id not found", 'NOT_FOUND');
    }
}

Clients send the operations with the same transports as the OpenAPI clients (Batch::run() included) and return typed results; errors in the response become a GraphQLException (errors(), errorCode(), and data() with what was resolved):

$shop = new ShopClient(headers: ['Authorization' => "Bearer $token"]);
$order = $shop->getOrder('7')->order;      // GetOrder\Order: id, status (Model\Status), customer...

One GraphQL schema is served per project; any number can be called as clients. Subscriptions are not generated yet.

API contracts (AsyncAPI)

exen generates message handlers and producers from AsyncAPI 3.0 and 2.x documents (YAML or JSON), for the jobs workers of linkfast/exengine-roadrunner: RoadRunner's jobs plugin delivers the messages to PHP workers that run the same worker.php as HTTP.

exen new orders --api events.yml              # a new project (RoadRunner, in-memory pipelines)
exen contract:add events.yml                  # the application of the contract: receives and sends its messages
exen contract:add billing-events.yml --client # the other side: receives what the contract sends, and vice versa
exen rr:jobs --driver=amqp                    # the pipelines in .rr.yaml

Each channel is a RoadRunner pipeline (its x-rr-pipeline, else its address; characters other than letters, digits, _ and - become dashes, since RoadRunner reads dots in its configuration keys as nesting: orders.placed is pipeline orders-placed) and each message a task name (its name, else its key). In 3.0, operations with action: receive are handled and action: send are produced; in 2.x, publish operations are handled and subscribe ones produced. For a contract named orders:

PathContents
src/Generated/Orders/Model/Payload models, validated like the OpenAPI ones
src/Generated/Orders/Api/<Channel>Consumer.phpPer channel the application receives from, an interface with one method per message
src/Generated/Orders/Producer/<Channel>Producer.phpPer channel it sends to, a class to inject with one method per message
src/Generated/Orders/OrdersContract.phpExtension: registers the producers and maps pipelines and messages to the handlers
src/Orders/<Channel>Handler.phpYours: the handlers, created once (they throw until written), never overwritten
final class OrderPlacedHandler extends Component implements OrderPlacedConsumer
{
    public function __construct(private readonly OrderShippedProducer $shipping)   // components can be injected
    {
    }

    public function onOrderPlaced(Model\Order $payload, Message $message): void
    {
        // ... store the order; returning acknowledges the message, throwing retries it (roadrunner.jobs.on-error)
        $this->shipping->shipOrder(new Model\OrderShipped($payload->id, Model\OrderShippedCarrier::Dhl));
    }
}

Payloads that do not match the contract are rejected without retries. Failures follow roadrunner.jobs.on-error (retry with growing delays up to max-attempts, nack or discard; by pipeline too): see the extension's README.

Pipelines. contract:add asks in a terminal which queue driver to write to .rr.yaml (--pipelines=<driver> or none; without a terminal it keeps the current driver, memory at first), and exen rr:jobs [--driver] rewrites them later. exen owns the lines between # exen:jobs and # exen:jobs end; exen rr:init --force keeps them, and a jobs: section written by hand is never touched. Drivers: memory (development), boltdb, amqp, kafka, nats, sqs and beanstalk, with their connections from environment variables (AMQP_ADDR, KAFKA_BROKERS, ...). exen serve runs as many jobs workers as HTTP workers.

API contracts (gRPC)

exen generates gRPC services and clients from .proto files. The services run on RoadRunner's grpc plugin with linkfast/exengine-grpc; the message classes come from protoc, the official protobuf compiler, so they match google/protobuf.

exen new shop --api catalog.proto                     # a new project (RoadRunner, the grpc extension, protoc)
exen protoc:install                                   # protoc matching the project's google/protobuf, in .protoc/
exen contract:add catalog.proto                       # the services this project implements
exen contract:add billing.proto --client --endpoint http://billing:9001   # a client of another service

exen protoc:install downloads protoc into .protoc/ (ignored by git), in the version that goes with the project's google/protobuf (v5.36.2 → protoc 36.2); EXEN_PROTOC points to another one, and one on the PATH works too. exen reads the services with the project's google/protobuf. For a contract named catalog:

PathContents
src/Generated/Proto/Message classes written by protoc (namespaces from the .proto file: php_namespace, else the package), autoloaded with a classmap exen adds to composer.json
src/Generated/Catalog/Api/<Service>Api.phpWhat the application implements: one method per RPC, (Request $request, Call $call): Response
src/Generated/Catalog/Server/<Service>Server.phpThe service RoadRunner's gRPC server calls
src/Generated/Catalog/Client/<Service>Client.phpClients (--client): unary calls over HTTP/2 with ext-curl
src/Generated/Catalog/CatalogContract.phpExtension: the services and their implementations
src/Catalog/<Service>Service.phpYours: the implementation, created once (UNIMPLEMENTED until written), never overwritten
final class CatalogService extends Component implements CatalogApi
{
    public function __construct(private readonly ProductRepository $products)   // components can be injected
    {
    }

    public function getProduct(GetProductRequest $request, Call $call): Product
    {
        return $this->products->find($request->getId())
            ?? throw GrpcError::notFound("Product {$request->getId()} not found");   // the client gets NOT_FOUND
    }
}
$catalog = new CatalogClient(metadata: ['authorization' => "Bearer $token"]);
$product = $catalog->getProduct(new GetProductRequest(['id' => 'p1']));   // GrpcException on a status other than OK

When the project has a server contract and a .rr.yaml, exen generate keeps a grpc: section in it (between # exen:grpc and # exen:grpc end: the .proto files and tcp://0.0.0.0:9001; the address and the pool you change are kept), exen rr:init --force keeps it, and exen serve runs as many gRPC workers as HTTP workers. Unary RPCs are generated; streaming RPCs are reported and left out (RoadRunner's PHP workers answer unary RPCs).

Extensions

exen extension:add monolog                       # activated automatically
exen extension:add monolog --manual-activation   # installed, activated by you
exen extension:add monolog --no-config           # without adding its default settings
exen extension:add monolog:^1.0 --dev
exen extension:add acme/exengine-metrics         # any other ExEngine extension, by Composer package
exen extension:list
exen extension:remove monolog

First-party extensions are installed by name, with the version constraint exen knows for them (a constraint after : replaces it):

NamePackage
graphqllinkfast/exengine-graphqlGraphQL endpoint (schema-first, persisted queries)
grpclinkfast/exengine-grpcgRPC services of .proto contracts on RoadRunner
monologlinkfast/exengine-monologMonolog as the ExEngine logger
roadrunner (rr)linkfast/exengine-roadrunnerRoadRunner workers, with services that live as long as the worker

Other packages are given by Composer name. exen first asks Composer for the package's type (in Packagist and the repositories of the project's composer.json) and installs it only if it is an ExEngine extension; any other library is refused with the composer require command to use instead.

ExEngine extensions are Composer packages of type exengine-extension. Like Spring Boot auto-configurations, they are activated automatically: once installed, ExEngine discovers them at boot and there is nothing to register.

With --manual-activation, exen installs the extension but adds its class to exengine.extensions.exclude in config/application.* (the ExEngine equivalent of spring.autoconfigure.exclude), so it stays inactive until you activate it in AppConfig:

$this->extension(\ExEngine\Monolog\MonologExtension::class);

Run extension:add again without the flag to switch back to automatic activation. extension:list shows the activation of each extension (automatic, manual or discovery-disabled) for the active profiles and environment, and extension:remove also removes the class from the exclude list.

exen edits application.json as data and application.yml as text, keeping comments; a YAML edit is only saved when ext-yaml confirms the result (otherwise exen shows the change to make). application.php files are never edited. A profile file with its own exengine.extensions.exclude replaces the base list when that profile is active; exen warns about it.

Default settings. extension:add also adds the settings the extension declares, with their default values, to config/application.yml (or .json), so they are visible and ready to edit:

# linkfast/exengine-monolog: default settings, edit them as needed
monolog:
  enabled: true
  stream: 'php://stderr'
  level: debug

exen reads them from the extension itself, using the project's ExEngine: the values passed to ExtensionContext::defaults() and the defaults of its #[ConfigProperties] classes (placeholders such as ${REDIS_URL} are kept as written). Only missing keys are added; values already in the file are never changed. In YAML, a commented block is appended for each new section; if the section exists but lacks some keys, exen shows them instead of editing. Settings without a default value are reported so you can set them. Use --no-config to skip this step.

extension:remove only removes ExEngine extensions.

Project layout settings

exen reads the project layout from composer.json:

"extra": {
    "exengine": {
        "launcher": "public/api.php",
        "controllers": "src/Controllers",
        "namespace": "App\\Controllers",
        "source": "src",
        "source-namespace": "App",
        "config": "config"
    }
}

Those are the defaults; adjust them for existing projects with a different layout.

Environment variables

VariableDescription
EXEN_COMPOSERPath to composer, composer.phar or a PHP script to use instead of the one on PATH
EXEN_RR_BINARYPath to an rr binary to use instead of the project's (e.g. one installed system-wide)
EXEN_RR_DOWNLOAD_URLBase URL (or folder) of RoadRunner releases for rr:install, e.g. a mirror
EXEN_NO_DELEGATIONSet to always run the global exen, even inside a project with its own copy
NO_COLORDisable colored output

Development

Tests need PHP 8.4+ (PHPUnit 13) and use the ExEngine core as a dev dependency. To test against a local checkout of the core without changing composer.json, use a local Composer file (ignored by git):

php -r '$c = json_decode(file_get_contents("composer.json"), true); $c["repositories"] = [["type" => "path", "url" => "../exengine", "options" => ["versions" => ["linkfast/exengine" => "1.3.0"]]]]; file_put_contents("composer.local.json", json_encode($c, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));'
COMPOSER=composer.local.json composer install
php vendor/bin/phpunit

The CI tests against the core branch or tag in EXENGINE_REF (default master); leave it empty to use the core published on Packagist.

Releasing

  1. Update CHANGELOG.md.
  2. Create an annotated tag on main, without a "v" prefix like the ExEngine core: git tag -a 1.0.0 -m "..." && git push origin 1.0.0.
  3. Packagist publishes the tag (register the repository once at https://packagist.org/packages/submit and enable the GitLab integration).

License

MIT, see LICENSE.