celema / core
Celema core web framework
Requires
- php: ^8.5
- ext-fileinfo: *
- ext-mbstring: *
- celema/container: ^0.6
- celema/router: ^0.5
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/http-message-implementation: ^1.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- psr/log: ^3.0
Requires (Dev)
- carthage-software/mago: 1.50.0
- celema/server: ^0.2
- ernst/coverlyzer: ^0.3
- infection/infection: 0.35.6
- nyholm/psr7: ^1.5
- nyholm/psr7-server: ^1
- phpunit/phpunit: ^13.0
- vimeo/psalm: 6.19.0
Suggests
- nyholm/psr7: Default PSR-7/PSR-17 implementation used by App::create()
- nyholm/psr7-server: Required alongside nyholm/psr7 for the default factory
Provides
None
Conflicts
None
Replaces
None
README
Celema Core is a lightweight and easily extendable PHP 8.5+ web framework.
[!WARNING] This library is under active development, some of its features are still experimental and subject to change. Large parts of the documentation are missing.
It features:
- Http Routing.
- An autowiring container used for automatic dependency injection.
- Middleware.
- Error handling for PSR-15 request pipelines.
- Convenience wrappers for PSR request, response and middleware.
- Logging.
Routing
App exposes the router's common route helpers and runs requests through the router RoutingHandler internally.
use Celema\Core\App; use Celema\Router\Group; $app = App::create(); $app->get('/health', [HealthController::class, 'show'], 'health'); $app->map(['GET', 'POST'], '/login', [AuthController::class, 'login'], 'login'); $app->any('/webhook', $webhook, 'webhook'); $app->group('/admin', function (Group $admin) use ($auth): void { $admin->middleware($auth); $admin->controller(AdminController::class); $admin->get('', 'index', 'admin.index'); $admin->post('/login', 'login', 'admin.login'); });
Request lifecycle
Let the front controller call serve():
// public/index.php require dirname(__DIR__) . '/vendor/autoload.php'; $app = require dirname(__DIR__) . '/app/bootstrap.php'; return $app->serve();
serve() selects the runtime. Started as a FrankenPHP worker, it handles requests until the worker retires. Everywhere else (PHP-FPM, FrankenPHP's classic mode, the CLI development server) it handles the current request and returns its response. The same file works in all of them.
Every request goes through the same steps:
- The app opens a container scope for the request. Routing, middleware, controllers and their autowired arguments resolve through that scope, so
scoped()entries get one instance per request. - The error handler, if any, wraps routing, middleware and the view.
- The response is emitted.
- The teardown runs: the scope is reset (services implementing
Celema\Container\Resettableare reset), then the app's teardown hooks run.
run() performs these steps for one request; handle() implements PSR-15's RequestHandlerInterface and returns the response without emitting it, which is useful in tests. In handle(), the teardown runs before the response is returned, so a response body must not depend on scoped services.
Register routes, middleware, and container entries before the first request. The first request seals the container; registering afterwards throws.
Teardown hooks
Use teardown() for resources that outlive a request and have to be brought back to a clean state after each one:
$app->teardown(static function () use ($mailer): void { $mailer->disconnect(); });
Hooks run in registration order. Each one runs even if an earlier one failed.
Failures
The error handler logs the server errors it answers without a matching renderer at critical, and the exceptions of a renderer entry at the level set with its log() method, as Server error {status} for {method} {path} or Client error …. The context carries the exception and the placeholder values; the path leaves out the query string, which can carry tokens. Without a logger, or when the logger fails, records go to error_log(). PHP diagnostics the handler does not turn into exceptions, deprecations by default, are logged at notice as PHP {type}: {diagnostic} in {file} on line {line}; without a logger, PHP reports them itself.
Exceptions thrown while handling a request are the error handler's job. A throwable that escapes it, or the emitter, is logged as Unhandled exception for {method} {path} through the PSR-3 logger registered with $app->logger() (otherwise with error_log()), and run() answers with a minimal 500 response if nothing was sent yet. Like PHP for an uncaught exception, that response shows the exception only while display_errors is on. In debug mode without a debug handler, the error handler lets exceptions escape on purpose, so they end up here. A failing teardown step is logged the same way; it never replaces the response that was already emitted.
Worker mode
FrankenPHP's worker mode boots the application once and keeps it in memory for many requests. Point the worker at the normal front controller:
example.org { root * /srv/site/public php_server { worker { file /srv/site/public/index.php num 4 } } }
What lives for the whole worker and what lives for one request:
- The app, its router, routes, middleware instances,
Before/Afterhandler instances, the error handler and its renderers, and allshared()container entries live as long as the worker. They must not keep the state of a single request. - Each request gets a new container scope with its own
scoped()instances, new controllers, and new autowired view arguments. - A shared entry cannot depend on a scoped one: the container throws instead of keeping the first request's instance. Pass request data as arguments, or make the consumer scoped too. Do not register the PSR request or values derived from it in the container; take them from the request passed to middleware and views.
The worker retires, and FrankenPHP starts a fresh one, in these cases:
- A throwable escaped the error handler or the emitter, or the teardown failed. The response was answered and logged as described above; retiring keeps whatever state the failed request left behind away from the next one.
- The configured number of requests was reached:
CELEMA_WORKER_MAX_REQUESTS(default0, no limit). - Memory use exceeds
CELEMA_WORKER_MAX_MEMORYin bytes,K,MorG(default 80 % ofmemory_limit,0disables it).
Set both in the worker's environment (env CELEMA_WORKER_MAX_REQUESTS 1000 in the worker block) or pass them to serve(maxRequests: …, maxMemory: …), which wins over the environment. FrankenPHP's max_consecutive_failures stops a worker that fails while booting.
Things to keep in mind:
- Code, configuration and other files read at boot are only read again by a new worker: restart FrankenPHP or its workers after a deployment, for example through the admin API's
POST /frankenphp/workers/restart. - FrankenPHP rebuilds
$_SERVERfor every request: values a script writes into it at boot, for example a.envloader, are gone in the requests, while process environment variables are available in each request.$_ENVandputenv()persist across requests and are shared by all threads of the process. - The worker clears PHP's file stat cache before each request, so
filemtime()andfilesize()see changes made by other processes. - Code must finish a request by returning a response.
exit()anddie()end the worker; FrankenPHP restarts it, at the cost of a fresh boot. - The logger is shared too. One that buffers records or keeps per-request state, such as Monolog with a
BufferHandler,FingersCrossedHandlerorUidProcessor, needs a reset after each request:$app->teardown(static fn() => $logger->reset());. - Each worker keeps its own resources, such as a database connection, open between requests. Budget the database connections for the number of workers of all sites sharing a database server.
Response::sendfile()choosesX-Accel-RedirectorX-Sendfilefrom$_SERVER['SERVER_SOFTWARE']per request.
Development server
The development server commands live in the optional celema/server package, which runs applications with the PHP CLI's built-in server or FrankenPHP:
composer require --dev celema/server
When the package is installed, Core's error handler automatically reports handled server errors to the development server's request log.
Development example
The repository's example app exercises routing, autowiring, request and response helpers, middleware, error handling, static assets, and request-log states. With celema/server installed, run it on port 1973 with either development server:
./app/run server ./app/run frankenphp
Add --watch to run BrowserSync and reload when the example or Core source changes. Both commands support host, port, request-log filtering, and BrowserSync-backed --watch mode.
PSR-7 implementation
App::create() uses nyholm/psr7 as its PSR-7/PSR-17 implementation:
composer require nyholm/psr7 nyholm/psr7-server
Any other implementation works through a custom Celema\Core\Factory\Factory. Extend AbstractFactory, assign the implementation's PSR-17 factories, and pass an instance to the App constructor:
use Celema\Core\Factory\AbstractFactory; use GuzzleHttp\Psr7\HttpFactory; use GuzzleHttp\Psr7\ServerRequest; use Psr\Http\Message\ServerRequestInterface; class Guzzle extends AbstractFactory { public function __construct() { $factory = new HttpFactory(); $this->requestFactory = $factory; $this->responseFactory = $factory; $this->serverRequestFactory = $factory; $this->streamFactory = $factory; $this->uploadedFileFactory = $factory; $this->uriFactory = $factory; } public function serverRequest(): ServerRequestInterface { return ServerRequest::fromGlobals(); } } $app = new App(new Guzzle(), new Router(), new Container());
Supported PSRs:
- PSR-3 Logger Interface
- PSR-4 Autoloading
- PSR-7 Http Messages (Request, Response, Stream, and so on.)
- PSR-11 Container Interface
- PSR-12 Extended Coding Style
- PSR-15 Http Middleware
- PSR-17 Http Factories
Mutation testing
Mutation testing with Infection is not part of composer ci, but the CI workflow runs it after the coverage step and enforces the minimum mutation score from infection.json5.dist. Pushes only mutate the changed lines; a weekly scheduled run covers the whole codebase. Run it locally with:
composer mutation
Reports are written to .infection/.
License
This project is licensed under the MIT license.
The built-in SAPI emitter is derived from laminas/laminas-httphandlerrunner (BSD-3-Clause); see the third-party code section in LICENSE.md.