linkfast / exengine-cli
exen: command line tool for the ExEngine microframework (new projects, dev server, generators, extensions).
Requires
- php: >=8.1.0
Requires (Dev)
- google/protobuf: ^5.36
- linkfast/exengine: ^1.3.0-a1
- linkfast/exengine-graphql: ^1.0.0-a1
- linkfast/exengine-grpc: ^1.0.0-a1
- linkfast/exengine-roadrunner: ^1.0.0-a3
- phpunit/phpunit: ^13.0
- webonyx/graphql-php: ^15.0
Suggests
- ext-yaml: Lets ExEngine projects read config/application.yml (exen new generates JSON configuration without it).
Provides
None
Conflicts
None
Replaces
None
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 useconfig/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
| Command | Description |
|---|---|
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 serve | Run 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 generate | Regenerate the code of the API contracts (--check in CI) |
exen docker:init | Create a Dockerfile, .dockerignore and compose.yaml for the project (--mode=multi or single) |
exen rr:install | Download the RoadRunner server (rr) for this system into the project |
exen rr:init | Create worker.php and .rr.yaml to run the project on RoadRunner |
exen rr:jobs | Write the RoadRunner pipelines of the AsyncAPI contracts to .rr.yaml (--driver=memory, amqp, kafka...) |
exen protoc:install | Download protoc (the protobuf compiler) into the project, for gRPC contracts |
exen routes | List the routes of the project controllers |
exen doctor | Check 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:list | List 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 | |
|---|---|
rest | HTTP REST: controllers with automatic routing and JSON responses |
openapi | Contract-first REST: server and client generated from OpenAPI 3 documents (--api, see below) |
graphql | Schema-first GraphQL with linkfast/exengine-graphql: server and clients generated from SDL (--api schema.graphql, see below) |
grpc | Services and clients generated from .proto files, served by RoadRunner (--api orders.proto, see below); adds RoadRunner to the servers |
asyncapi | Message 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):
| Server | Generated files |
|---|---|
builtin | Nothing: PHP's built-in server through exen serve, for development |
apache | public/.htaccess (mod_rewrite front controller) |
nginx | deploy/nginx.conf (server block for PHP-FPM) |
caddy | deploy/Caddyfile (php_fastcgi to PHP-FPM) |
frankenphp | deploy/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:
| Path | Contents |
|---|---|
public/api.php | Launcher; the web server document root is public/ |
src/AppConfig.php | Registration of components, filters, listeners and properties classes |
src/Controllers/Hello.php | Sample controller: /Hello/world |
config/application.yml | Properties, plus application-prod.yml for the prod profile |
composer.json | Requires 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:
| Path | Contents |
|---|---|
src/MetricsExtension.php | Extension::register(): binds the settings and registers the service |
src/MetricsProperties.php | #[ConfigProperties('metrics')] settings with their defaults (metrics.enabled) |
src/MetricsService.php | A component applications inject |
tests/ | Boots ExEngine with the extension and checks the settings and the service (vendor/bin/phpunit) |
composer.json | Requires linkfast/exengine; extra.exengine.extension names the extension class |
README.md, CHANGELOG.md, LICENSE | Ready 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):
| Server | Image | Run |
|---|---|---|
roadrunner | php:cli with the official rr binary | rr serve -c .rr.yaml, port 8080 |
frankenphp | dunglas/frankenphp, worker mode | deploy/frankenphp/Caddyfile, port 8080 |
nginx, caddy | php:fpm (app) and nginx/caddy (web) targets | docker compose up, with docker/nginx.conf or docker/Caddyfile |
apache | php: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 runscomposer install --no-devfromcomposer.jsonandcomposer.lock, so the image comes from the sources alone;.dockerignoreleavesvendor/out. - Single stage (
single): the project is copied as it is,vendor/included; the pipeline (or you) runscomposer install --no-dev --optimize-autoloaderbeforedocker 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:
| Path | Contents |
|---|---|
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.php | One 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.php | Clients (--client) |
src/Generated/Orders/OrdersContract.php | Extension that registers the routes and binds each interface to its implementation |
src/Orders/OrdersService.php | Yours: 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
400RFC 9457 Problem Details (application/problem+json), each error naming the parameter or pointing into the body ("pointer": "#/lines/0/quantity"). ThrowApiExceptionfor an error the contract does not declare. - When a contract gains operations,
exen generateshows the methods to add to your implementation. AppConfigactivates the contracts withGenerated/contracts.php(exen adds the lines once).- The server uses the explicit routes of
linkfast/exengine1.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:
| Path | Contents |
|---|---|
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.php | The 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.php | Yours: 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:
| Path | Contents |
|---|---|
src/Generated/Orders/Model/ | Payload models, validated like the OpenAPI ones |
src/Generated/Orders/Api/<Channel>Consumer.php | Per channel the application receives from, an interface with one method per message |
src/Generated/Orders/Producer/<Channel>Producer.php | Per channel it sends to, a class to inject with one method per message |
src/Generated/Orders/OrdersContract.php | Extension: registers the producers and maps pipelines and messages to the handlers |
src/Orders/<Channel>Handler.php | Yours: 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:
| Path | Contents |
|---|---|
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.php | What the application implements: one method per RPC, (Request $request, Call $call): Response |
src/Generated/Catalog/Server/<Service>Server.php | The service RoadRunner's gRPC server calls |
src/Generated/Catalog/Client/<Service>Client.php | Clients (--client): unary calls over HTTP/2 with ext-curl |
src/Generated/Catalog/CatalogContract.php | Extension: the services and their implementations |
src/Catalog/<Service>Service.php | Yours: 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):
| Name | Package | |
|---|---|---|
graphql | linkfast/exengine-graphql | GraphQL endpoint (schema-first, persisted queries) |
grpc | linkfast/exengine-grpc | gRPC services of .proto contracts on RoadRunner |
monolog | linkfast/exengine-monolog | Monolog as the ExEngine logger |
roadrunner (rr) | linkfast/exengine-roadrunner | RoadRunner 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
| Variable | Description |
|---|---|
EXEN_COMPOSER | Path to composer, composer.phar or a PHP script to use instead of the one on PATH |
EXEN_RR_BINARY | Path to an rr binary to use instead of the project's (e.g. one installed system-wide) |
EXEN_RR_DOWNLOAD_URL | Base URL (or folder) of RoadRunner releases for rr:install, e.g. a mirror |
EXEN_NO_DELEGATION | Set to always run the global exen, even inside a project with its own copy |
NO_COLOR | Disable 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
- Update
CHANGELOG.md. - 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. - Packagist publishes the tag (register the repository once at https://packagist.org/packages/submit and enable the GitLab integration).
License
MIT, see LICENSE.