Config Files Are Just PHP Arrays
Open a Symfony project and the routes are in YAML. The services are in YAML. The bundles are in PHP, but only as an array of class names. Open a Laravel project and routing lives in PHP, but services hide behind ServiceProviders that wrap bindings inside register() and boot() methods. Both approaches put a layer between you and the configuration, and that layer is where every typo, missing key, and "where is this interface bound" question accumulates.
The problem
Configuration is just a data structure. Routes pair a verb-and-path with a controller. The container pairs an interface with a concrete class. The event registry pairs an event class with one or more listeners. The RBAC table pairs a role with a list of permission strings. None of this needs a parser. None of this needs a DSL. None of this needs a compiler.
What it does need is to be readable, refactorable, and statically checkable. YAML fails on all three. The IDE does not know that a class string in YAML is a class. Renaming the class with PhpStorm leaves the YAML untouched. A typo in a service ID surfaces at runtime, when the container tries to resolve it and throws.
Service providers do better than YAML on the typo front, because the bindings are PHP. They do worse on the readability front: finding "which provider registers this interface" means grepping through a stack of register() methods that all share the same $this->app->bind() shape.
The mechanism
In Phexium, every config file is a PHP file that returns an array (or a closure that takes the framework instance). No subclass to extend. No DSL to learn. Just return [...].
Routes:
return function (App $app): void {
$app->get('/', [HomeController::class, 'index']);
$app->group('/books', function (RouteCollectorProxy $group): void {
$group->get('/create', [HttpCreateBookController::class, 'showCreateForm']);
$group->post('/create', [HttpCreateBookController::class, 'createBook']);
})->add($rbac->forPermission('book.create'));
$app->get('/books/{id}', [HttpDetailBookController::class, 'index']);
};
Container bindings:
return [
CommandBusInterface::class => function (ContainerInterface $c): CommandBusInterface {
$syncBus = $c->get(SyncCommandBus::class);
$transaction = $c->get(TransactionInterface::class);
return new TransactionalCommandBus($syncBus, $transaction);
},
EventBusInterface::class => autowire(SyncEventBus::class),
IdGeneratorInterface::class => autowire(TimestampIdGenerator::class),
];
Events:
return [
BookCreatedEvent::class => [
BookEventHandler::class,
],
LoanCreatedEvent::class => [
LoanCreatedEventHandler::class,
],
UserAuthenticatedEvent::class => [
UserAuthenticatedEventHandler::class,
],
];
Permissions:
return [
'admin' => [
'book.create',
'book.update',
'book.delete',
'loan.view_all',
],
'user' => [
'loan.view_own',
'loan.borrow',
],
];
The bootstrap loads the file, receives the array, and uses it. No compilation step. No cache to clear when a class is renamed. No schema to validate.
Trade-offs
The PHP-array approach is not free.
You cannot hand the config to a non-developer. A YAML file can be opened by an ops engineer, a tech writer, or a junior who does not write PHP. A PHP file with namespaces, use statements, and closures cannot. If your team needs a non-coder editing routes, this approach hurts.
You cannot generate the config from an external tool without parsing PHP. A YAML config can be templated from any language. A PHP config returning an array of Class::class references requires PHP-aware tooling.
Static analysis on dynamic bindings has limits. The demo container uses pattern strings like 'AppDemo\*\Domain\*Repository' to wire repositories generically. Rector and PHPStan see the string, not the resolution. That trade is the price of the wildcard.
For a framework targeting developers who already write PHP every day, these costs are low.
What this enables
The IDE works on the config file the same way it works on the rest of the codebase.
Renaming HttpCreateBookController with PhpStorm updates routes.php automatically, because the IDE sees a real class reference and not a string. Renaming BookCreatedEvent updates events.php the same way.
Jumping to definition works. Click on LoanCreatedEventHandler::class inside events.php and the editor opens the handler. No "go to definition unavailable" because the value is a string in YAML.
Static analysis works. PHPStan reads the array, sees that the keys are class strings, and flags missing classes. Rector applies its rules to config files like any other PHP file. PHP-CS-Fixer formats them with the same standard.
Refactoring is one operation. When you rename book.create to book.write, find-and-replace covers permissions.php, the route definitions, the controllers, and the templates in one pass.
The config is code, with all the tooling code gets. That is the most boring choice possible, and that is the point.