echo-fusion / framework
The EchoFusion framework: a kernel that boots configuration, modules, service providers, events and plugins and turns a PSR-7 request into a response, built from small replaceable packages.
Requires
- php: ~8.1.0 || ~8.2.0 || ~8.3.0
- ext-mbstring: *
- echo-fusion/authmanager: ^1.0
- echo-fusion/configmanager: ^1.0
- echo-fusion/contracts: ^2.0
- echo-fusion/databasemanager: ^1.0
- echo-fusion/eventmanager: ^2.0
- echo-fusion/middlewaremanager: ^2.1
- echo-fusion/modulemanager: ^1.0
- echo-fusion/pluginmanager: ^3.0
- echo-fusion/responsemanager: ^1.0
- echo-fusion/routemanager: ^3.0
- echo-fusion/servicemanager: ^3.1
- echo-fusion/sessionmanager: ^1.0
- echo-fusion/templatemanager: ^1.0
- guzzlehttp/psr7: ^2.5
- psr/log: ^1.1 || ^2.0 || ^3.0
- vlucas/phpdotenv: ^5.6
Requires (Dev)
- echo-fusion/codestandard: ^1.0
- friendsofphp/php-cs-fixer: ^3.64
- phpunit/phpunit: ^9.5
- vimeo/psalm: ^5.26
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-12 15:59:51 UTC
README
The EchoFusion framework: a small kernel that boots configuration, modules, service providers, events and plugins, and turns a PSR-7 request into a response. Everything it uses is a separate EchoFusion package behind an interface from echo-fusion/contracts, so any part can be replaced.
Part of EchoFusion, a modular PHP framework built from small packages.
Documentation: docs/ (start with the blog tutorial). A complete example application (accounts, posts, admin panel, JSON API) is in examples/blog and installs with composer create-project echo-fusion/echo-fusion my-blog dev-blog.
Start a project
Most people want the skeleton, which contains the folders, config files and front controller:
composer create-project echo-fusion/echo-fusion my-app cd my-app composer serve # http://localhost:8000
To use the framework in an existing project, require it and create the application yourself (see The front controller):
composer require echo-fusion/framework
Requirements
- PHP 8.1, 8.2 or 8.3
- ext-mbstring
What is inside
| Part | Package | Replace it by binding another implementation of |
|---|---|---|
| Container | servicemanager | the container is the composition root, not replaced |
| Configuration | configmanager | ConfigInterface |
| Router | routemanager | RouterInterface, RouteMatcherInterface |
| Middleware | middlewaremanager | MiddlewareManagerInterface |
| Events (PSR-14) | eventmanager | EventManagerInterface, or any PSR-14 dispatcher |
| Plugins | pluginmanager | PluginManagerInterface |
| Modules | modulemanager | ModuleManagerInterface |
| Sessions | sessionmanager | SessionInterface |
| Responses | responsemanager | PSR-7 |
| Templates | templatemanager | TemplateManagerInterface |
| Database | databasemanager | ConnectionInterface (PDO, Doctrine DBAL, your ORM) |
| Authentication | authmanager | GuardInterface, UserProviderInterface |
The front controller
// public/index.php require __DIR__ . '/../vendor/autoload.php'; EchoFusion\Framework\Application::create(__DIR__ . '/..')->run();
Application::create($basePath) boots the application; run() reads the request from PHP's globals, handles it and sends the response. In tests you skip the web server:
$app = Application::create($basePath, 'testing'); $response = $app->handle(new ServerRequest('GET', '/posts/5')); // a PSR-7 response, nothing is sent
How it boots
Booting happens once, in this order. Knowing it tells you where to hook in.
- Environment.
.envis loaded (variables already set in the real environment win) and the environment name is chosen: the constructor argument, elseAPP_ENV, elseproduction. - Configuration. Every
config/<name>.phpreturns an array and becomes the section<name>.config/environments/<environment>.phpis merged on top. See Configuration. - Modules listed in
app.modulescontribute configuration (below the application's own), providers, routes and template directories. - Container and core services. The container is created and the services the framework itself provides are registered (configuration, events, plugins, session, router, middleware, error handler, PSR-17 factories).
- Service providers. The core providers (templates, database, auth), then the providers of the modules, then
app.providers. All providers are registered first, and only then booted, each with its configuration section, so a provider can rely on every other provider's services. - Application wiring. Routes are loaded (modules first, then your
config/routes.php), module templates are added, services fromconfig/services.phpare defined. - Listeners from
config/events.phpare attached. - Plugins from
app.pluginsare registered for the current environment. ApplicationBootedis dispatched.
How a request is handled
Request ─► RequestReceived ─► global middleware (middleware.core)
└─► route ─► middleware of that route ─► action ─► response
Response ◄─ ResponsePrepared ◄──────────────────────────────────────────────────────────┘
any exception ─► ExceptionRaised ─► error handler
- The router matches the request. A placeholder matches letters, digits,
_and-unless you give it a constraint ('constraints' => ['name' => '[^/]+']). No match is a 404, a wrong method on an existing path a 405 with anAllowheader. - The route's middleware runs inside the global middleware. A name is looked up in
middleware.route; a class name works too. - The action is a closure,
[Controller::class, 'method'](the controller is built by the container) or the name of an invokable class. Its arguments come from the signature:- a parameter named like a route placeholder gets the value, URL-decoded (
/hello/caf%C3%A9givescafé) and converted to the parameter's type (/posts/{id}withint $idgives an int; a value that is not a number is a 404); Request(the helper described below, or your subclass of it) orServerRequestInterface(raw PSR-7) gets the request; the placeholders are also request attributes (route,route.params,id);- any other class type is taken from the container;
- otherwise the default value is used.
- a parameter named like a route placeholder gets the value, URL-decoded (
- What the action returns becomes the response: a PSR-7 response as it is, text as HTML,
nullas an empty 204, anything else (array, object, number) as JSON.
Reading the request: Request
Ask for Request as an action argument and read GET, POST and JSON data without touching PSR-7:
use EchoFusion\Framework\Http\Request; public function store(Request $request): array { $title = $request->input('title'); // form field, JSON field or query string, in that order of priority $page = $request->query('page', 1); // only ?page= $email = $request->post('email'); // only the submitted form $tag = $request->json('tags.0'); // only the JSON body, dot keys reach inside $data = $request->only('title', 'body'); // just these keys $age = $request->int('age'); // typed: int(), float(), string(), bool() $request->method(); $request->path(); $request->header('Accept'); $request->cookie('theme'); $request->file('avatar'); // a PSR-7 UploadedFileInterface $request->bearerToken(); $request->wantsJson(); $request->ip(); $request->user(); // the authenticated user (after the 'auth' middleware) $request->attribute('id'); // a route placeholder $request->psr(); // the PSR-7 request itself }
input()merges the query string under the body, so the body wins.all(),has(),filled(),only()andexcept()work on that merged data and accept dot keys (filter.status).- Forms of
PUT,PATCHandDELETEare read from the body too (PHP only fills$_POSTforPOST). A body marked as JSON that is not valid JSON answers400. - Need the raw PSR-7 request? Type-hint
ServerRequestInterfaceinstead. - Your own request type:
final class StorePostRequest extends Request { public function title(): string { ... } }and type-hint that in the action; the framework builds it. It is a good place for validation.
CSRF protection
Http\Middleware\VerifyCsrfToken protects every form against cross-site request forgery. Every session gets a random token; a request that changes something (not GET, HEAD or OPTIONS) must send it back as the field _token or the header X-CSRF-TOKEN, otherwise the answer is 419 Page Expired.
// config/middleware.php 'core' => [StartSessionMiddleware::class, VerifyCsrfToken::class],
// config/app.php: paths that need no token. APIs that authenticate with a header token are not exposed to CSRF. 'csrf' => ['except' => ['/api/*']],
<form method="post" action="/posts"> <input type="hidden" name="_token" value="{{ csrf }}"> <!-- the action passes ['csrf' => $request->csrfToken()] --> ... </form>
JavaScript calls send the token in the X-CSRF-TOKEN header.
Configuration
config/
app.php name, debug, timezone, modules, providers, plugins
middleware.php core (global middleware) and route (alias => class)
routes.php the routes (an array, or a function that receives the router)
events.php listeners
services.php extra container definitions
templates.php settings of templatemanager
database.php settings of databasemanager
auth.php settings of authmanager
session.php cookie and storage settings of the session
environments/
production.php partial config merged on top in production
Use env() in config files, and nowhere else:
use function EchoFusion\Framework\env; return [ 'name' => env('APP_NAME', 'EchoFusion'), 'debug' => env('APP_DEBUG', false), // "true" / "false" / "null" become real values 'timezone' => 'UTC', 'modules' => [], // classes implementing ModuleInterface 'providers' => [], // see below 'plugins' => [], // classes implementing PluginInterface ];
Routes
// config/routes.php: an array ... return [ 'home' => ['method' => 'GET', 'path' => '/', 'action' => [HomeController::class, 'index']], 'post' => ['method' => 'GET', 'path' => '/posts/{id}', 'action' => [PostController::class, 'show'], 'constraints' => ['id' => '[0-9]+'], 'middlewares' => ['auth']], ];
// ... or a function, when you prefer code use EchoFusion\Contracts\RouteManager\RouteRegistrarInterface; return static function (RouteRegistrarInterface $router): void { $router->get('home', '/', [HomeController::class, 'index']); };
Middleware
// config/middleware.php return [ 'core' => [StartSessionMiddleware::class], // runs for every request, in this order 'route' => ['auth' => AuthenticateMiddleware::class], // names you can use in a route's 'middlewares' ];
Middleware is built from the container when a request reaches it.
Events
// config/events.php return ['listeners' => [ UserRegistered::class => [ SendWelcomeMail::class, // an invokable class, built by the container [AuditLog::class, 'onRegistered'], // [class, method] ['listener' => ProfileUpdater::class, 'priority' => -10] // lower priority runs later ], ]];
The framework dispatches ApplicationBooted, RequestReceived (replace $event->request), ResponsePrepared (replace $event->response) and ExceptionRaised (set $event->response to take over error handling).
Providers
A service provider registers services in the container and is configured by one section. Core providers are always there; add yours:
'providers' => [ AppServiceProvider::class, // booted with an empty configuration PaymentServiceProvider::class => 'payments', // booted with the section "payments" (config/payments.php) ],
Services
// config/services.php return [ 'dependencies' => [ MailerInterface::class => SmtpMailer::class, // a class name ... Clock::class => static fn () => new Clock('UTC'), // ... or a factory that receives the container ], 'shared' => [MailerInterface::class], // built once instead of on every get() ];
Auto-wiring builds any class whose constructor asks for classes the container knows (or has defaults for).
Modules and plugins
A module declares data: config, providers, routes and a template directory (see modulemanager). A plugin runs code at start-up for chosen environments and receives the container (see pluginmanager); use one for things like a debug bar in dev or an error tracker in production.
Command line
Every application gets a console. Put this in bin/console:
#!/usr/bin/env php <?php declare(strict_types=1); use EchoFusion\Framework\Application; require __DIR__ . '/../vendor/autoload.php'; exit(Application::create(dirname(__DIR__))->runConsole($argv));
php bin/console # list the commands php bin/console help migrate # help for one command
| Command | What it does |
|---|---|
about |
Environment, debug flag, PHP and framework version, paths, number of routes and modules |
route:list |
All routes; filter with --method=, --path=, --name= |
config:show [key] |
The merged configuration, or one value (database.connections.main); secrets are masked |
serve |
PHP's built-in server for public/ (--host=, --port=, --dry-run) |
cache:clear |
Delete the compiled templates and other files in storage/cache |
migrate |
Run the migrations in database/migrations that have not run yet |
migrate:rollback |
Undo the last batch |
migrate:status |
Which migrations ran, which are pending |
make:controller Admin/Post |
Create app/Http/Controllers/Admin/PostController.php |
make:migration create_posts_table --create=posts |
Create a migration file |
make:command SendReport |
Create a console command |
Exit codes: 0 success, 1 the command failed, 2 wrong usage (unknown command or option, missing argument).
A mistyped command name gets suggestions.
Migrations
A migration is a file that returns an object with up() and down(); each one runs in a transaction.
<?php use EchoFusion\Contracts\DatabaseManager\ConnectionInterface; use EchoFusion\Framework\Database\Migration; return new class implements Migration { public function up(ConnectionInterface $db): void { $db->execute('CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL)'); } public function down(ConnectionInterface $db): void { $db->execute('DROP TABLE posts'); } };
Files run in name order (2026_01_01_120000_create_posts_table.php); what ran is recorded in the migrations
table. Use --database=name to migrate another connection from config/database.php.
Your own commands
final class SendReportCommand extends Command { public function name(): string { return 'report:send'; } public function description(): string { return 'Email the weekly report'; } public function arguments(): array { return ['to' => ['description' => 'Recipient', 'required' => true]]; } public function options(): array { return ['dry-run' => ['description' => 'Do not send']]; } public function handle(Input $input, Output $output): int { $output->info('Sending to ' . $input->argument('to')); return self::SUCCESS; } }
List the class in config/console.php (return ['commands' => [SendReportCommand::class]];). Commands given as class
names are built by the container, so their constructors can ask for any service.
Errors
Nothing escapes handle(): every exception becomes a response by the error handler.
Exceptions\HttpExceptionanswers with its own status, so a controller canthrow new HttpException(403, 'You cannot edit this post.').- API clients (
Accept: application/json) get JSON; everyone else gets HTML, from the templateerrors/<status>orerrors/errorwhen they exist, otherwise from a small built-in page. - With
app.debugon, the message, class, file and trace are shown. With it off, a server error only says "Internal Server Error". - A request that PSR-7 cannot represent (for example a malformed header name sent by the client) is answered with
400 Bad Requestbyrun(), instead of a PHP error that would show file paths. The request is built byHttp\GlobalsRequestFactory, a service you can replace. - Bind a PSR-3
LoggerInterfacein the container and server errors are logged. - Replace everything by binding your own
Http\ErrorHandlerInterface.
Replacing a part
Every service is looked up by an interface id, and providers run after the core services, so a provider can rebind it:
final class AppServiceProvider implements ServiceProviderInterface { public function register(SettableContainerInterface $container): void { $container->set(ConnectionInterface::class, fn () => new DoctrineConnection($dbalConnection), true); } public function boot(SettableContainerInterface $container, array $config = []): void { } }
Testing
composer test
The test suite builds small throw-away applications on disk and sends requests through Application::handle().
Credits
License
The MIT License (MIT). Please see LICENSE for more information.