The Myth of the Micro-Framework
"Micro-framework" gets sold as simplicity. Pick a router, handle requests, stay out of your way. The reality is different: micro doesn't mean simple, it means incomplete by design. The decisions the framework skips become your decisions - and in a project that grows, those decisions accumulate.
The problem
When you choose a micro-framework for a real project, you face two paths.
The first: you reimplement everything the framework left out. You pick a DI container, or build one. You decide how commands get dispatched to handlers, how database transactions wrap your writes, how domain events propagate after a state change. Each of these decisions costs time. Each implementation you write is one more thing to test, maintain, and debug. Because you build it under deadline pressure, it ends up fragile.
The second path: you pull in packages. One for DI, one for a command bus, one for events. The problem is coordination. These packages don't know about each other. The event package doesn't know that events should only dispatch after the transaction commits. The command bus doesn't know how your DI container resolves handlers. You spend weeks writing the glue code, and the architecture slowly loses coherence as each new developer makes slightly different choices on the edges.
Neither of these is a "micro-framework is lightweight" story. They're both "micro-framework shifted work onto you" stories.
The mechanism
Phexium takes the opposite approach. The framework makes decisions on cross-cutting concerns: dependency injection (PHP-DI), command bus, query bus, transaction management, event dispatch, session, RBAC. These aren't suggestions - they're the default stack, and they're designed to work together.
Here's what a typical write operation looks like:
<?php
declare(strict_types=1);
namespace AppDemo\Library\Application\Command;
use AppDemo\Library\Domain\Repository\BookRepository;
use Phexium\Plugin\Transaction\Port\TransactionManagerInterface;
final class CreateBookHandler
{
public function __construct(
private readonly BookRepository $books,
private readonly TransactionManagerInterface $transaction,
) {}
public function __invoke(CreateBookCommand $command): void
{
$this->transaction->execute(function () use ($command): void {
$book = Book::create(
id: BookId::fromString($command->id),
title: Title::fromString($command->title),
isbn: ISBN::fromString($command->isbn),
);
$this->books->save($book);
});
}
}
The handler gets TransactionManagerInterface injected by PHP-DI. The command bus resolves and calls the handler. The transaction wraps the persistence. These three pieces know about each other because they were designed together.
Event dispatch follows the same pattern. Events are collected during the transaction and dispatched after commit - not inside it. You don't configure this; it's how the framework works.
The plugin system (Port & Adapter) means each component has an interface and at least one implementation. If you need to swap the transaction manager for a different database driver, you implement the interface and bind it in the container. The rest of the stack doesn't change.
// config/container.php
use Phexium\Plugin\Transaction\Port\TransactionManagerInterface;
use Phexium\Plugin\Transaction\Adapter\PdoTransactionManager;
return [
TransactionManagerInterface::class => \DI\autowire(PdoTransactionManager::class),
];
Trade-offs
You take on Phexium's opinions. If you need fundamentally different command bus behavior, you'll fight the framework rather than work with it.
The stack is also more to learn upfront. Understanding how PHP-DI, the command bus, and the transaction manager interact takes time for a new developer.
If your project is genuinely small - a simple CRUD app with no domain logic - most of this infrastructure is overhead you don't need. A micro-framework is the right call for that case.
What this enables
When the infrastructure decisions are made and coordinated, you stop writing glue code and start writing domain code. Every handler follows the same pattern. Every event dispatch happens the same way. A new developer can read one handler and understand the structure of the whole application.
The plugin system means "opinionated" doesn't mean "locked in." You can swap the SQLite repository for a PostgreSQL one by implementing BookRepository and updating one container binding. The command bus doesn't care. The transaction manager doesn't care.
The framework made the decisions on the parts that don't differentiate your product. What you do inside the handlers, how you model your domain, how you structure your modules - those are still yours.