linkfast / exengine
API-first Microframework for PHP 8.1+
Requires
- php: >=8.1.0
- ext-json: *
Requires (Dev)
- phpdocumentor/shim: ^3.9
- phpunit/phpunit: ^13.0
Suggests
- ext-redis: Optional Redis cache for parsed configuration files (RedisPropertiesCache).
- ext-yaml: Reads application.yml / application-{profile}.yml configuration files (pecl install yaml). Not needed for application.json or application.php.
- linkfast/zero-template: Lightweight template rendering engine.
- webonyx/graphql-php: Adds GraphQL support, a modern way to build HTTP APIs consumed by web and mobile clients.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-16 10:37:08 UTC
README
A lightweight, modern PHP microframework for building JSON APIs
ExEngine is a high-performance, API-first framework for PHP 8.1+ designed for rapid development of RESTful APIs and microservices. Built with dependency injection, filters, and automatic routing at its core.
It is also a good fit for a Backend for Frontend (BFF): a thin API layer per client (web app, mobile app) that aggregates and adapts the backend services that client needs, with filters for authentication and request shaping, standard JSON responses, and long-running workers (RoadRunner) that keep connections and services alive between requests.
© LinkFast S.A. (https://linkfast.io)
Features
- 🚀 Zero Configuration - Get started with minimal setup
- 🎯 Auto-Routing - Convention-based URL routing to controllers
- 💉 Dependency Injection - Built-in DI container for components
- 🔄 Request/Response Filters - Middleware-style request lifecycle hooks
- 📦 REST Controllers - First-class support for RESTful APIs
- 🪵 Flexible Logging - Multiple logger implementations (JSON, ErrorLog; Monolog as an extension)
- 📊 Structured Responses - Automatic JSON serialization with standard response format
- 🎨 DataObjects - Type-safe data serialization with null suppression
- ⚡ Production Mode - Optimized performance with disabled runtime checks
- ⚙️ Configuration Properties -
application.ymlwith profiles, environment overrides and typed binding - 🪝 Lifecycle Events - Hook into every stage of the request lifecycle
- 🧩 Extensions - Packaged extensions discovered from Composer
- 🧭 Backend for Frontend - Thin, client-specific API layers in front of your services
- 🔧 Highly Extensible - Component-based architecture
Requirements
- PHP 8.1 or higher
- ext-json
- Optional:
ext-yaml(pecl install yaml) to readapplication.yml(JSON and PHP property files need nothing) - Optional:
ext-redisto cache configuration in Redis
ExEngine has no Composer dependencies: it only uses PHP and, optionally, the PHP extensions above.
Installation
New Project with the exen CLI
composer global require linkfast/exengine-cli:^1.0.0-a1
exen new my-api
cd my-api
exen serve
exen new creates a ready-to-run project (launcher in public/, controllers in src/Controllers, properties in
config/). See exengine-cli for all commands (make:*, routes,
doctor, extension:*).
Via Composer (Recommended)
composer require linkfast/exengine:^1.3.0-a1
1.3.0 is in development and published as pre-releases (1.3.0-a1, 1.3.0-a2, ...): the version constraint
lets Composer install them in projects with the default minimum-stability: stable.
Manual Installation
Download the latest release and include the bootstrap file:
require 'exengine/src/bootstrap.php';
Quick Start
1. Create Your Launcher
Create an api.php file in your web-accessible directory:
<?php
require 'vendor/autoload.php';
use ExEngine\ExEngine;
// Default setup with default configuration
new ExEngine(__DIR__);
2. Create a Controller
Create App/Hello.php:
<?php
namespace App;
use ExEngine\Controller;
class Hello extends Controller
{
public function world(): array
{
return [
"message" => "Hello, World!",
"timestamp" => time()
];
}
}
3. Test It
Navigate to: http://localhost/api.php/Hello/world
Response:
{
"took": 0,
"code": 200,
"data": {
"message": "Hello, World!",
"timestamp": 1711526280
},
"error": false
}
Core Concepts
Controllers
ExEngine supports two types of controllers:
Standard Controllers
Standard controllers extend ExEngine\Controller and use method-based routing:
<?php
namespace App;
use ExEngine\Controller;
class User extends Controller
{
public function profile(string $userId): array
{
return [
"userId" => $userId,
"name" => "John Doe"
];
}
public function settings(): array
{
return ["theme" => "dark"];
}
}
URLs:
GET /api.php/User/profile/123→profile("123")GET /api.php/User/settings→settings()
REST Controllers
REST controllers extend ExEngine\RestController and map HTTP methods automatically:
<?php
namespace App;
use ExEngine\RestController;
class Article extends RestController
{
public function get(?array $arguments): array
{
// GET /api.php/Article/123
$id = $arguments[0] ?? null;
return ["id" => $id, "title" => "Article Title"];
}
public function post(?array $arguments): array
{
// POST /api.php/Article
$title = $_POST['title'];
return [
"__useStandardResponse" => true,
"result" => "created",
"id" => 456
];
}
public function put(?array $arguments): array
{
// PUT /api.php/Article/123
return ["result" => "updated"];
}
public function delete(?array $arguments): array
{
// DELETE /api.php/Article/123
return ["result" => "deleted"];
}
}
Configuration
ExEngine provides flexible configuration through the Config class:
MinimalConfig (Recommended)
For quick setup with essential configuration:
<?php
require 'vendor/autoload.php';
use ExEngine\ExEngine;
use ExEngine\Config\MinimalConfig;
use ExEngine\Config\Routing;
use ExEngine\Enum\Config\RoutingType;
use ExEngine\Logger\ErrorLogLogger;
const EXENGINE_LOG = true;
const EXENGINE_LOG_WEB = true;
new ExEngine(
new MinimalConfig(
__DIR__,
new Routing(RoutingType::CONTROLLER, 'Home/index'),
ErrorLogLogger::class
)
);
Custom Configuration
Extend the Config class for full control:
<?php
namespace App;
use ExEngine\Config;
use ExEngine\Config\Routing;
use ExEngine\Enum\Config\RoutingType;
class AppConfig extends Config
{
public function __construct(string $launcherPath)
{
parent::__construct($launcherPath);
// Set namespace for controllers
$this->setNamespace('App');
// Set controllers location
$this->setControllersLocation('Controllers');
// Enable production mode
$this->setProduction(false);
// Configure default routing
$this->setDefaultRoutingConfig(
new Routing(RoutingType::CONTROLLER, 'Home/index')
);
// Register components
$this->component(Database::class);
$this->component(new Cache("redis://localhost"));
// Register filters
$this->requestFilter(AuthFilter::class);
$this->responseFilter(CorsFilter::class);
}
}
Configuration Properties
Application settings live in property files in the config/ folder next to your launcher (change it with
$this->setPropertiesLocation('path') in your Config):
# config/application.yml
app:
name: Orders API
database:
host: ${DB_HOST:localhost} # environment variable with a default
port: 5432
password: ${DB_PASSWORD} # required: boot fails if it is not set
url: "pgsql://${database.host}:${database.port}/orders" # references other properties
# config/application-prod.yml (only loaded when the "prod" profile is active)
exengine:
production: true
database:
host: db.internal
Files and profiles. ExEngine reads application.{yml,yaml,json,php} (first one found), then
application-{profile}.* for each active profile, in order. Active profiles come from the EXENGINE_PROFILES
environment variable (EXENGINE_PROFILES=prod,eu) or, if it is not set, from exengine.profiles.active in
application.yml. Maps are merged key by key; lists are replaced as a whole. YAML files need the yaml
extension (pecl install yaml); with RoadRunner, enable it for the PHP CLI that runs the workers.
Inside YAML flow collections ({ ... }, [ ... ]), quote placeholders: { host: "${DB_HOST}" }, because
} and ] close the collection. Block style (host: ${DB_HOST}) needs no quotes.
Environment overrides. Any existing key can be overridden by an environment variable named after it:
database.port → DATABASE_PORT, database.max-connections → DATABASE_MAX_CONNECTIONS. The value is
converted to the type of the value it replaces. Only the process environment and $_ENV are read.
Typed properties. Bind a section to a class and inject it anywhere:
<?php
use ExEngine\Configuration\ConfigProperties;
#[ConfigProperties('database')]
final class DatabaseProperties
{
public string $host = 'localhost';
public int $port = 5432;
public ?string $password = null;
public int $maxConnections = 10; // also read from max-connections or max_connections
}
// In your Config constructor
$this->bindProperties(DatabaseProperties::class);
// In a controller or component
public function __construct(DatabaseProperties $database) { /* ... */ }
Values are converted to the declared types (int, float, bool, string, array, enums and nested classes). A missing value keeps the property default; a missing value without default, or a value of the wrong type, fails the boot with the list of invalid keys.
You can also inject ExEngine\Configuration\Properties and read values by path:
$properties->get('app.name'), $properties->isProfileActive('prod').
Framework settings. Keys under exengine configure ExEngine itself and override the Config constructor:
production, namespace, controllers-location, default-route, error-handler, logger,
show-header-banner, json-pretty-print, suppress-nulls, profiles.active and extensions.*.
Caching. Long-running workers read the files once per worker. With PHP-FPM, cache the parsed files so YAML is not parsed on every request; cache entries are invalidated when a file changes:
use ExEngine\Configuration\PhpFilePropertiesCache;
use ExEngine\Configuration\RedisPropertiesCache;
// PHP files kept in memory by OPcache (fastest on a single server)
$this->setPropertiesCache(new PhpFilePropertiesCache(__DIR__ . '/var/cache'));
// Redis (\Redis from ext-redis), shared by several instances
$this->setPropertiesCache(new RedisPropertiesCache($redis, ttl: 3600));
Dependency Injection
ExEngine features a powerful dependency injection system for components:
Registering Components
<?php
class AppConfig extends Config
{
public function __construct(string $launcherPath)
{
parent::__construct($launcherPath);
// Register by class name (new instance per injection)
$this->component(DatabaseService::class);
// Register by instance (singleton)
$this->component(new CacheService("redis://localhost"));
// Register with custom name
$this->component(LogService::class, 'customLogger');
}
}
Using Components
Components are automatically injected into controller constructors:
<?php
namespace App;
use ExEngine\Controller;
class User extends Controller
{
private DatabaseService $db;
private CacheService $cache;
public function __construct(
DatabaseService $db,
CacheService $cache
) {
$this->db = $db;
$this->cache = $cache;
}
public function list(): array
{
$cached = $this->cache->get('users');
if ($cached) return $cached;
$users = $this->db->query("SELECT * FROM users");
$this->cache->set('users', $users);
return $users;
}
}
Built-in Injectable Types
ExEngine automatically injects these types:
ExEngine\ExEngine- The framework instanceExEngine\Logger- Logger instanceExEngine\Http\Request- The request being handled:method(),path(),uri(),query(),post(),header(),headers(),server()andbody()(->raw()/->array())
<?php
use ExEngine\Controller;
use ExEngine\Logger;
use ExEngine\Http\Request;
class MyController extends Controller
{
public function __construct(
Logger $logger,
Request $request
) {
$logger->info("Controller instantiated");
$logger->debug("Request URI", ["uri" => $request->uri()]);
}
}
Filters
Filters provide middleware-style request/response processing:
Request Filters
Execute before controller methods:
<?php
namespace App\Filters;
use ExEngine\RequestFilter;
use ExEngine\FilterException;
class AuthFilter extends RequestFilter
{
public function execute(array &$filterData): mixed
{
$token = $_SERVER['HTTP_AUTHORIZATION'] ?? null;
if (!$token) {
throw new FilterException("Unauthorized", 401);
}
// Validate token and store user data
$user = $this->validateToken($token);
$filterData['user'] = $user;
return $user;
}
private function validateToken(string $token): array
{
// Token validation logic
return ["id" => 1, "name" => "John"];
}
}
Response Filters
Execute after controller methods:
<?php
namespace App\Filters;
use ExEngine\ResponseFilter;
class CorsFilter extends ResponseFilter
{
public function execute(mixed $controllerResponse): mixed
{
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
return $controllerResponse;
}
}
Registering Filters
<?php
class AppConfig extends Config
{
public function __construct(string $launcherPath)
{
parent::__construct($launcherPath);
// Request filters execute in order
$this->requestFilter(AuthFilter::class);
$this->requestFilter(ValidationFilter::class);
// Response filters execute in order
$this->responseFilter(CorsFilter::class);
$this->responseFilter(CompressionFilter::class);
}
}
Filter Control in Controllers
<?php
class PublicController extends Controller
{
// Exclude specific filters
public array $__ignoredFilters = [AuthFilter::class];
// Or allow only specific filters
public array $__allowedFilters = [CorsFilter::class];
}
DataObjects
Type-safe data serialization with automatic JSON conversion.
Configuration API
DataObjects use a fluent configuration API through the config() method:
<?php
use ExEngine\DataObject;
use ExEngine\DataObject\Config\DataObjectConfig;
use ExEngine\DataObject\Config\ExposeConfig;
use ExEngine\Enum\Data\SuppressNulls;
class Product extends DataObject
{
protected int $id;
protected string $name;
protected ?string $description = null;
public function __construct(int $id, string $name)
{
$this->id = $id;
$this->name = $name;
// Method 1: Full configuration
$this->config(new DataObjectConfig(
new ExposeConfig(
SuppressNulls::TRUE, // Suppress null values
true // Add object name to output
),
"ProductData" // Custom object name
));
// Method 2: Fluent configuration
$this->config()->expose()->suppressNulls(SuppressNulls::FALSE);
$this->config()->expose()->addObjectName(false);
$this->config()->name("CustomName");
}
}
SuppressNulls Options
SuppressNulls::TRUE- Always suppress null valuesSuppressNulls::FALSE- Never suppress null valuesSuppressNulls::GLOBAL- Use global config setting (default)
Basic Example
<?php
use ExEngine\DataObject;
use ExEngine\DataObject\Config\DataObjectConfig;
use ExEngine\DataObject\Config\ExposeConfig;
use ExEngine\Enum\Data\SuppressNulls;
class UserResponse extends DataObject
{
protected int $id;
protected string $name;
protected ?string $email;
protected ?string $phone = null;
public function __construct(int $id, string $name, ?string $email)
{
$this->id = $id;
$this->name = $name;
$this->email = $email;
// Configure DataObject behavior
$this->config(new DataObjectConfig(
new ExposeConfig(SuppressNulls::TRUE, false), // Suppress nulls, don't add object name
"User" // Custom object name (optional)
));
}
}
// In controller
public function getUser(): DataObject
{
return new UserResponse(1, "John Doe", "john@example.com");
}
Output:
{
"took": 0,
"code": 200,
"data": {
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"_name": "User"
},
"error": false
}
Note: By default, DataObjects include a _name field with the object's class name or custom name. To disable this, set addObjectName to false in the ExposeConfig:
$this->config(new DataObjectConfig(
new ExposeConfig(SuppressNulls::TRUE, false) // Second parameter disables _name field
));
Logging
ExEngine provides flexible logging with multiple implementations:
Enable Logging
<?php
// In your launcher file
const EXENGINE_LOG = true; // Enable logging
const EXENGINE_LOG_WEB = true; // Include errors in HTTP responses
const EXENGINE_TRACE = true; // Enable detailed trace (development only)
Logger Types
ErrorLogLogger (default) - Writes with error_log(), in color only when it writes to a terminal (php -S,
the CLI); log files, PHP-FPM, Apache, RoadRunner and FrankenPHP get plain text. const EXENGINE_LOG_COLORS = true;
(or false) in the launcher forces it, and the NO_COLOR environment variable turns colors off:
use ExEngine\Logger\ErrorLogLogger;
$logger = new ErrorLogLogger("MyApp");
JsonLogger - Structured JSON logs:
use ExEngine\Logger\JsonLogger;
$logger = new JsonLogger("MyApp");
Monolog - Available as an extension, so the core keeps no third-party dependencies:
exen extension:add monolog # or: composer require linkfast/exengine-monolog
Once installed it is discovered and used automatically; configure it in application.yml
(see exengine-monolog). ExEngine\Logger\MonoLogger from ExEngine 1.2 moved
to this package, which keeps the old class name working.
Using Loggers
<?php
$logger = logger("MyController");
$logger->debug("Debug message", ["key" => "value"]);
$logger->info("Info message");
$logger->warning("Warning message");
$logger->error("Error message", [], $exception);
$logger->critical("Critical message");
// Create child loggers
$childLogger = $logger->child("SubModule");
Routing
URL Structure
http://domain.com/api.php/[Controller]/[Method]/[Arg1]/[Arg2]/...
http://domain.com/[Controller]/[Method]/[Arg1]/[Arg2]/... (web server rewriting URLs to api.php)
The launcher can stay in the URL, or the web server can send every request for a file that does not exist to
api.php (Apache RewriteRule, Nginx try_files, Caddy and FrankenPHP php_server, RoadRunner). Both forms
route the same way; links generated by ExEngine keep the form of the request. ExEngine takes the route from
PATH_INFO when the server sets it, or from the URL path relative to the launcher's folder, so an application in
a sub-folder (/app/api.php) answers /app/User/profile. Sample configurations are in config/; exen new
generates them for the server you choose.
Examples
| URL | Controller | Method | Arguments |
|---|---|---|---|
/api.php/User/profile/123 | User | profile | ["123"] |
/api.php/Article/edit/456/draft | Article | edit | ["456", "draft"] |
/api.php/Admin/Dashboard/stats | Admin/Dashboard | stats | [] |
Default Routing
Configure default route when no controller is specified:
<?php
use ExEngine\Config\Routing;
use ExEngine\Enum\Config\RoutingType;
// Redirect to controller method
$this->setDefaultRoutingConfig(
new Routing(RoutingType::CONTROLLER, 'Home/index')
);
// Serve SPA (Single Page Application)
$this->setDefaultRoutingConfig(
new Routing(RoutingType::SPA, '/path/to/index.html')
);
Explicit Routes
Routes map an HTTP method and a path template to a controller method. They are tried before the convention-based URLs, which keep working:
<?php
// In your Config constructor
$this->route('GET', '/orders/{id}', OrdersController::class, 'show');
$this->route('POST', '/orders', OrdersController::class, 'create');
// Or a list, the format of generated route files
$this->routes([
['GET', '/orders/{id}/lines/{line}', OrdersController::class, 'line'],
]);
<?php
use ExEngine\Controller;
use ExEngine\Http\Response;
class OrdersController extends Controller
{
public function show(string $id): array // {id} arrives as a named argument
{
return ['id' => $id];
}
public function create(): Response // a Response is sent as it is: status, headers, body
{
return new Response(201, ['Location' => '/orders/7', 'Content-Type' => 'application/json'], '{"id":"7"}');
}
}
{name}matches one path segment (URL-decoded); literal paths win over templates, then routes are tried in registration order. HEAD requests use GET routes.- A path with routes for other methods only answers
405 Method Not Allowedwith anAllowheader. - Controller classes are autoloaded when a request matches, so a large route table costs nothing at boot.
- Filters, lifecycle events and dependency injection work as for convention-based controllers. Arrays keep the
StandardResponseenvelope of standard controllers (REST controllers return them as they are). - Extensions can register routes with
ExtensionContext::routes().
HTTP Method Validation
Ensure controller methods only accept specific HTTP methods:
<?php
use ExEngine\Controller;
use ExEngine\Enum\Http\HttpMethod;
class User extends Controller
{
public function create(): array
{
// Only accept POST requests
$this->accept(HttpMethod::POST);
$name = $_POST['name'];
return ["result" => "created", "name" => $name];
}
public function update(): array
{
// Accept multiple methods
$this->accept([HttpMethod::PUT, HttpMethod::PATCH]);
return ["result" => "updated"];
}
}
Redirects and Links
Redirects
<?php
// Redirect to method in same controller
$this->redirect('otherMethod', $arg1, $arg2);
// Redirect to another controller
$this->instance()->redirect('OtherController', 'method', $arg1);
// Permanent redirect (HTTP 301)
$this->redirectPermanent('newMethod');
// Redirect to URL
$this->instance()->redirect('https://example.com');
$this->instance()->redirect('/relative/path');
Generate Links
<?php
// Link to method in same controller
$url = $this->link('method', $arg1, $arg2);
// Link to another controller
$url = $this->instance()->link('Controller', 'method', $arg1);
// Link by URL
$url = $this->instance()->linkByUrl('path/to/resource');
Response Formats
Standard Response (Default)
{
"took": 0,
"code": 200,
"data": {
"key": "value"
},
"error": false
}
Raw Response
Return string to bypass standard response:
<?php
public function raw(): string
{
return "<h1>Raw HTML</h1>";
}
Or disable standard response in arrays:
<?php
public function custom(): array
{
return [
"__useStandardResponse" => false,
"custom": "format"
];
}
Output:
{
"custom": "format"
}
Error Handling
Built-in Exceptions
ExEngine\NotFoundException- HTTP 404ExEngine\RequestException- HTTP 400ExEngine\ResponseException- HTTP 500ExEngine\FilterException- Filter errorsExEngine\ConfigException- Configuration errors
<?php
use ExEngine\NotFoundException;
use ExEngine\RequestException;
public function getUser(string $id): array
{
if (!is_numeric($id)) {
throw new RequestException("Invalid user ID", ["id" => $id]);
}
$user = $this->db->find($id);
if (!$user) {
throw new NotFoundException("User not found");
}
return $user;
}
Custom Error Handler
<?php
class AppConfig extends Config
{
public function __construct(string $launcherPath)
{
parent::__construct($launcherPath);
// Redirect errors to custom handler
$this->errorHandler('Error/handle/{code}');
}
}
Production Mode
Enable production mode for optimized performance:
<?php
class AppConfig extends Config
{
public function __construct(string $launcherPath)
{
parent::__construct($launcherPath);
// Enable production mode
$this->setProduction(true);
// Disable version info header
$this->setShowHeaderBanner(false);
}
}
Production mode disables:
- Runtime class/method validation checks
- Development messages in responses
- Detailed error traces in HTTP responses
Advanced Features
Component Late Initialization
Initialize singleton components after registration:
<?php
class AppConfig extends Config
{
public function componentLateInit(ExEngine $instance): void
{
// Called after all components are registered
// Use for cross-component initialization
// You can manually trigger DI and configure components
$injected = $instance->injector()->inject(MyComponent::class);
$injected->configure();
// Also, you can create the instance manually. DI will not be used in this case.
$manuallyInstantiated = new MyComponent();
// Register the configured instance as a singleton
$this->component($injected, "configuredComponent");
$this->component($manuallyInstantiated, "manuallyInstantiatedComponent");
}
}
Development Messages
Add debug messages to responses (development mode only):
<?php
use ExEngine\ExEngine;
ExEngine::message("Debug info");
ExEngine::message(["key" => "value"]);
Response includes:
{
"took": 0,
"code": 200,
"data": {...},
"error": false,
"developmentMessages": [
"Debug info",
{"key": "value"}
]
}
Global Instance Access
Access ExEngine instance from anywhere:
<?php
// Using helper function
$instance = ee();
// Using static method
$instance = ExEngine::instance();
// Access configuration
$config = ee()->config();
// Access filter data
$filterData = ee()->filtersData();
// Get controller metadata
$meta = ee()->meta();
// Access dependency injector
$injector = ee()->injector();
Lifecycle Events
Listeners can hook every stage of the lifecycle. Register them in your Config (or from an extension) with
$this->listen(Event::class, $listener, $priority); higher priorities run first.
| Event | When | Listeners can |
|---|---|---|
Booted | Once, at the end of boot | Warm up caches, check connections |
RequestReceived | Before routing | Answer the request with respond() |
RouteResolved | Controller and method known, before request filters | Answer with respond() (e.g. authorization) |
ControllerExecuted | After the controller and response filters | Replace the result with setData() |
ExceptionThrown | An exception escaped the request | Answer with respond() instead of the default error |
ResponseReady | Every final response (success, redirect, error) | Replace it with setResponse() |
RequestTerminated | After the response was sent | Metrics, cleanup (errors are logged and ignored) |
ShuttingDown | On shutdown() (worker stop) | Release resources |
All events are in the ExEngine\Event namespace.
<?php
use ExEngine\Event\RequestReceived;
use ExEngine\Event\ResponseReady;
use ExEngine\Http\Response;
// Answer CORS preflight requests before routing
$this->listen(RequestReceived::class, function (RequestReceived $event) {
if ($event->request()->method() === 'OPTIONS') {
$event->respond(new Response(204, ['Access-Control-Allow-Origin' => '*']));
}
});
// Invokable class: instantiated once, with dependency injection
$this->listen(ResponseReady::class, SecurityHeaders::class, 10);
Extensions
Extensions add features to ExEngine without adding dependencies to the core: defaults, components, filters,
listeners and typed properties, packaged as Composer packages of type exengine-extension.
Adding extensions with the exen CLI
exen(linkfast/exengine-cli) is not published on Packagist yet; until then, use the Composer commands shown next to each step. Both do the same: extensions are regular Composer packages.
Install the CLI once, globally:
composer global require linkfast/exengine-cli:^1.0.0-a1
Then, from your project folder:
exen extension:add monolog # composer require linkfast/exengine-monolog
exen extension:add acme/exengine-metrics # any other ExEngine extension, by Composer package
exen extension:list # composer show --direct (lists every direct package)
exen extension:remove monolog # composer remove linkfast/exengine-monolog
First-party extensions are installed by name (monolog, roadrunner). Other packages are installed only if
they are ExEngine extensions (Composer type exengine-extension); exen checks it before installing. Installed
extensions are discovered automatically at boot; there is nothing to register in your Config. Each extension
documents its settings, which go in config/application.yml.
Official extensions:
| Package | Description |
|---|---|
linkfast/exengine-monolog | Monolog as the ExEngine logger |
linkfast/exengine-roadrunner | RoadRunner workers: ExEngine boots once per worker, with services that live as long as the worker |
Writing an extension
exen new:extension exengine-metrics creates a ready-to-publish extension package: the extension class,
a #[ConfigProperties] settings class, an injectable service, tests that boot ExEngine with it, README, CHANGELOG
and LICENSE.
An extension class registers what it contributes:
<?php
use ExEngine\Event\ResponseReady;
use ExEngine\Extension;
use ExEngine\ExtensionContext;
final class MetricsExtension implements Extension
{
public function register(ExtensionContext $context): void
{
$context
->defaults(['metrics' => ['header' => 'X-Response-Time']]) // overridable in application.yml
->bindProperties(MetricsProperties::class)
->component(MetricsCollector::class)
->listen(ResponseReady::class, ResponseTimeHeader::class);
}
}
Register it in your Config with $this->extension(MetricsExtension::class), or distribute it as a Composer package:
installed packages of type exengine-extension are discovered automatically.
{
"name": "acme/exengine-metrics",
"type": "exengine-extension",
"extra": { "exengine": { "extension": "Acme\\Metrics\\MetricsExtension" } }
}
Extensions can also decide how components registered as class names are created (by default, a new instance per injection). The RoadRunner extension uses this to keep some services for the life of a worker:
$context->componentFactory(function (string $name, string $class, callable $create): object {
return isSharedService($class) ? ($this->services[$class] ??= $create()) : $create();
});
Disable discovery with exengine.extensions.discover: false, or skip extensions with
exengine.extensions.exclude: [Acme\Metrics\MetricsExtension]. Extension classes need a constructor without required
arguments.
Long-Running Workers
Booting and handling a request are separate steps, so one ExEngine instance can serve many requests in a long-running worker (for example RoadRunner) instead of booting on every request:
<?php
use ExEngine\ExEngine;
use ExEngine\Http\Request;
// Boot once: configuration, logger and dependency injector
$engine = ExEngine::create(new AppConfig(__DIR__));
// Handle each request: nothing is printed, no headers are sent and the process is never stopped
$response = $engine->handle(new Request('GET', 'Hello/world'));
$response->status(); // 200
$response->headers(); // ['Content-type' => 'application/json']
$response->body(); // '{"took":0,"code":200,"data":{...},"error":false}'
new ExEngine(...) keeps working as before: it boots, handles the request built from PHP superglobals
and sends the response. Workers should call $engine->terminate($request, $response) after sending each
response (it dispatches RequestTerminated), and $engine->shutdown() when they stop.
Redirects (redirect(), redirectPermanent(), ...) stop the current request by throwing
ExEngine\Http\ResponseSignal, which ExEngine turns into the redirect response. If you wrap a redirect in
try { ... } catch (Exception $e), rethrow ResponseSignal. You can stop a request with any response
using ee()->halt(new Response(...)).
Examples
The framework includes comprehensive examples in the examples/ directory:
- Controllers - Standard and REST controller examples
- DependencyInjection - Component injection patterns
- Filters - Request/Response filter implementations
- Launcher - Various launcher configurations
Run examples locally:
cd examples/Controllers
./start.sh # on Windows, please use WSL.
Database Support
ExEngine is database-agnostic. Use any ORM or database library:
- Doctrine - Full-featured ORM
- PDO - Direct database connections
- Eloquent - Laravel's ORM (standalone)
Extensions & Ecosystem
Official Extensions
- Zero Template - Lightweight template engine
Related Projects
- RadiancePHP - Full-stack MVC framework built on ExEngine (linkfast/radiance)
Documentation
- Wiki: https://gitlab.com/linkfast/oss/exengine/wikis/home
- API Documentation: Generate HTML docs from PHPDoc using
php phpDocumentor.phar - Examples: Browse the
examples/directory
Generating API Documentation
ExEngine uses phpDocumentor to generate comprehensive HTML documentation from PHPDoc comments:
# Generate documentation
php phpDocumentor.phar --config=phpdoc.xml
# View documentation
open docs/api/index.html
The generated documentation includes:
- Complete API reference for all classes and methods
- Namespace and package organization
- Inheritance diagrams
- Cross-referenced code navigation
- Search functionality
Support
- Issues: https://gitlab.com/linkfast/oss/exengine/issues
- Source Code: https://gitlab.com/linkfast/oss/exengine
- Commercial Support: https://linkfast.io
Version Information
- Current Version: 1.3.0-a4 (pre-release; 1.3.0 is in development)
- API Level: 4
- Minimum PHP: 8.1.0
See CHANGELOG.md for the changes in each release.
Migration from Previous Versions
From 1.2 to 1.3
Applications using new ExEngine(...) keep working; review these changes:
- Monolog moved out of the core. If you use
ExEngine\Logger\MonoLogger, install the extension (composer require linkfast/exengine-monolog); it keeps that class name working. - Redirects no longer stop the script.
redirect(),redirectPermanent(),redirectByUrl()andredirectToDefault()throwExEngine\Http\ResponseSignal. If you wrap a redirect intry { ... } catch (Exception $e), rethrowResponseSignal. new ExEngine\Http\Request()no longer readsphp://input; injectRequestor useRequest::fromGlobals().- Fixed behaviour you may have worked around:
__ignoredFilters/__allowedFilterswere swapped; raw JSON responses contained"__useStandardResponse": false; an unknown controller answered with code-1;HttpExceptions thrown by controllers (e.g.NotFoundException) became 500 (or 400 for REST controllers) and now keep their status. - New optional
config/directory. If your launcher folder already has aconfig/application.*file for something else, ExEngine now reads it as configuration properties; move it or change the location withsetPropertiesLocation().
From earlier versions
ExEngine has evolved from a full MVC framework to a lightweight API-first microframework. If you're looking for the full MVC features from earlier versions, check out RadiancePHP, which provides a complete MVC framework built on top of ExEngine.
Contributing
Contributions are welcome! Please submit issues and pull requests to our GitLab repository.
License
The MIT License (MIT)
Copyright (c) 2018-2026 LinkFast S.A. (https://linkfast.io)
Copyright (c) 2016-2018 Giancarlo A. Chiappe Aguilar (gchiappe@linkfast.io)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
OR OTHER DEALINGS IN THE SOFTWARE.