Framework Documentation - Application Package
Routing and controllers
WebApplication is the concrete application class. It takes a router and a controller resolver
and implements doExecute() as: parse the route, merge the route variables into the input, call
the controller.
Constructing it
use Joomla\Application\Controller\ContainerControllerResolver;
use Joomla\Application\WebApplication;
use Joomla\Router\Router;
$router = new Router();
$router->get('/', HomeController::class);
$router->get('/articles/:id', ArticleController::class, ['id' => '\d+']);
$router->post('/articles', CreateArticleController::class);
$app = new WebApplication(
new ContainerControllerResolver($container),
$router,
$input,
$config
);
$app->execute();What doExecute() does
protected function doExecute(): void
{
$route = $this->router->parseRoute($this->get('uri.route'), $this->input->getMethod());
foreach ($route->getRouteVariables() as $key => $value) {
$this->input->def($key, $value);
}
\call_user_func($this->controllerResolver->resolve($route));
}Three consequences:
- Routing happens on the
uri.routeconfig key, whichloadSystemUris()derived from the request.
If your base URI detection is wrong, routing is wrong — setsite_uriexplicitly. - Route variables are merged with
def(), so they do not overwrite a request parameter of the
same name. A query string?id=99wins over the route segment/articles/5. If that matters,
read the route variables from theResolvedRouteinstead of from the input. - The controller is called with no arguments. Everything it needs must come from its
constructor.
Route definition
The router accepts one method per verb, plus all():
$router->get('/articles', ListController::class);
$router->post('/articles', CreateController::class);
$router->put('/articles/:id', UpdateController::class, ['id' => '\d+']);
$router->delete('/articles/:id', DeleteController::class, ['id' => '\d+']);
$router->all('/health', HealthController::class);Pattern syntax:
| Segment | Meaning |
|---|---|
articles |
Literal |
:id |
Named variable, matches [^/]*, or the rule you supply |
: |
Unnamed variable, not captured |
*rest |
Splat, captures everything remaining into rest |
* |
Splat, not captured |
Known routing limitation.
Router::parseRoute()throwsMethodNotAllowedExceptionat the
first route whose pattern matches but whose method does not, without looking at later routes.
Registeringget('/articles', …)andpost('/articles', …)therefore makes the POST route
unreachable. Until that is fixed in the router package, register one route per pattern with
all()and branch on$app->getInput()->getMethod()inside the controller.
Controller resolvers
ControllerResolver
Resolves, in order:
| Route controller value | Resolution |
|---|---|
[SomeClass::class, 'method'] |
Instantiates SomeClass, returns [$instance, 'method'] |
[$object, 'method'] |
Returned as-is |
An object with __invoke() |
Returned as-is |
| A function name | Returned as-is |
SomeClass::class (string) |
Instantiates it and returns [$instance, 'execute'] |
Anything else, or a class that cannot be instantiated, raises InvalidArgumentException.
ControllerResolver::instantiateController() calls new $class() — so a controller resolved this
way must have a constructor without required arguments. If it has any, you get an
InvalidArgumentException explaining exactly that.
ContainerControllerResolver
Subclass that first asks a PSR-11 container:
protected function instantiateController(string $class): object
{
if ($this->container->has($class)) {
return $this->container->get($class);
}
return parent::instantiateController($class);
}This is the one you want in practice — it lets controllers declare their dependencies:
$container->share(
ArticleController::class,
static fn (Container $c) => new ArticleController(
$c->get(ArticleRepository::class),
$c->get(TemplateRendererInterface::class), // your own templating abstraction
$c->get(WebApplicationInterface::class)
)
);Controllers are resolved from a class name that ultimately derives from the request. Register the
controllers you expect in the container and avoid building class names from request data — the
resolver has no namespace allowlist and will happily instantiate any class that exists.
Writing your own
use Joomla\Application\Controller\ControllerResolverInterface;
use Joomla\Router\ResolvedRoute;
final class ActionResolver implements ControllerResolverInterface
{
public function __construct(private ContainerInterface $container) {}
public function resolve(ResolvedRoute $route): callable
{
$controller = $route->getController();
if (!\is_string($controller) || !$this->container->has($controller)) {
throw new \InvalidArgumentException(
sprintf('Cannot resolve controller for URI `%s`', $route->getUri())
);
}
return $this->container->get($controller);
}
}Writing controllers
The resolver supports three shapes. Pick one and stay with it.
Invokable — the least ceremony:
final class HomeController
{
public function __construct(
private WebApplicationInterface $app,
private TemplateRendererInterface $templates
) {}
public function __invoke(): void
{
$this->app->setBody($this->templates->render('home', ['title' => 'Home']));
}
}ControllerInterface — resolved to execute(), and the shape joomla/controller expects:
use Joomla\Controller\ControllerInterface;
final class ArticleController implements ControllerInterface
{
public function __construct(
private ArticleRepository $articles,
private WebApplicationInterface $app
) {}
public function execute(): bool
{
$id = $this->app->getInput()->getUint('id');
$article = $this->articles->find($id);
if ($article === null) {
$this->app->setHeader('Status', '404', true);
$this->app->setBody('Not found');
return false;
}
$this->app->setBody($this->render($article));
return true;
}
}Array callable — one class, several actions:
$router->get('/articles', [ArticleController::class, 'index']);
$router->get('/articles/:id', [ArticleController::class, 'show'], ['id' => '\d+']);Note that with an array callable the class is instantiated by instantiateController(), so with
ContainerControllerResolver it still comes from the container.